72 Commits

Author SHA1 Message Date
itsMapleLeaf
3fb61f2812 Merge branch 'main' into rewrite-internals 2022-10-12 15:03:15 -05:00
itsMapleLeaf
b6f244aaa0 wip more stuff 2022-10-02 17:57:49 -05:00
itsMapleLeaf
dc6239e598 remove .js in imports for now
pending https://github.com/esbuild-kit/tsx/issues/74
2022-08-07 20:53:27 -05:00
itsMapleLeaf
d01c2a3bac lockfile 2022-08-07 14:00:11 -05:00
itsMapleLeaf
459cafdff2 fancy spinner for test setup 2022-08-07 13:01:20 -05:00
itsMapleLeaf
fd8f85ea89 simpler ReacordTester with more parallelizing 2022-08-07 12:57:14 -05:00
itsMapleLeaf
74bada9351 share more logic between renderers 2022-08-07 12:56:57 -05:00
itsMapleLeaf
bbf3c4ab17 improve async queue
resolve the promise for the added task immediately, instead of waiting on a promise for all tasks
2022-08-07 12:55:47 -05:00
itsMapleLeaf
ac3df750bc library -> src 2022-08-07 12:02:40 -05:00
itsMapleLeaf
4caaed09e9 remove manual test script
we have real tests now :)
2022-08-07 12:02:35 -05:00
itsMapleLeaf
6084ab23e0 more test fixes 2022-08-06 10:22:12 -05:00
itsMapleLeaf
e1f5eda3c7 helpers: add node types and tsconfig 2022-08-06 10:10:27 -05:00
itsMapleLeaf
14ebfee673 use verbose reporter 2022-08-06 09:52:56 -05:00
itsMapleLeaf
aafba45696 fix doc comments 2022-08-06 09:52:50 -05:00
itsMapleLeaf
1d2620304f work around actions cache skipping cypress install 2022-08-06 01:37:29 -05:00
itsMapleLeaf
8bd8177472 why 2022-08-06 01:24:41 -05:00
itsMapleLeaf
cdc3815ce2 remove random weird dev dep 2022-08-06 01:20:58 -05:00
itsMapleLeaf
49621c5d9d remove tailwindcss types 2022-08-06 00:54:47 -05:00
itsMapleLeaf
8443dfb019 upgrades 2022-08-06 00:54:02 -05:00
itsMapleLeaf
c572f16638 fix pnpm setup 2022-08-06 00:19:21 -05:00
itsMapleLeaf
cd22d75b3a clean up garbage 2022-08-06 00:15:15 -05:00
itsMapleLeaf
91c8e98e8c remove .only 2022-08-06 00:11:21 -05:00
itsMapleLeaf
57e0fd458c enable dep caching 2022-08-06 00:10:23 -05:00
itsMapleLeaf
a39d6295c4 reenable tests in ci 2022-08-06 00:10:09 -05:00
itsMapleLeaf
1cbd5e9bfd throw together some scuffed integration testing infra 2022-08-06 00:05:30 -05:00
itsMapleLeaf
e974f0073d ensure embed values aren't empty 2022-08-06 00:05:10 -05:00
itsMapleLeaf
d5617fd1b5 remove discord-js test 2022-08-05 23:49:07 -05:00
itsMapleLeaf
55b5072e1b run local build script 2022-08-05 23:48:52 -05:00
itsMapleLeaf
69d29d2aa3 lazily create action rows 2022-08-05 23:48:36 -05:00
itsMapleLeaf
6b261d647b support text for embed description 2022-08-05 23:48:13 -05:00
itsMapleLeaf
9c60c24dca message payload tweaks 2022-08-05 11:44:46 -05:00
itsMapleLeaf
3bd0b33750 pass around a client promise so renderers can await login 2022-08-05 11:44:23 -05:00
itsMapleLeaf
f58ec8d776 generate exports 2022-08-05 11:43:07 -05:00
itsMapleLeaf
b2281d51cb added integration test for action row 2022-08-05 11:42:57 -05:00
itsMapleLeaf
66054b31fc update vitest 2022-08-05 11:42:27 -05:00
itsMapleLeaf
f97b2f4816 generate exports before compile 2022-08-05 11:41:08 -05:00
itsMapleLeaf
339bf5a24f slight logical corrections in renderer 2022-08-05 09:06:47 -05:00
itsMapleLeaf
e38a4439c1 Merge branch 'main' of https://github.com/itsMapleLeaf/reacord into rewrite-internals 2022-08-04 14:41:35 -05:00
itsMapleLeaf
c0f2719171 added script to generate exports 2022-08-04 14:39:47 -05:00
itsMapleLeaf
3c59b5ac1e update doc for InteractionInfo 2022-08-04 14:39:10 -05:00
itsMapleLeaf
843b4ef9db remove some random comments and unneeded stuff 2022-08-04 14:37:54 -05:00
itsMapleLeaf
2c8742bc5f cleanup 2022-08-04 13:30:02 -05:00
itsMapleLeaf
ffa9357f73 rename manual test script 2022-08-04 13:02:37 -05:00
itsMapleLeaf
14d6f87dda untested rewrite 2022-08-04 10:29:06 -05:00
itsMapleLeaf
5852b4a616 flatten file structure 2022-08-01 22:49:31 -05:00
itsMapleLeaf
4171b7326a new structure with renderer skeleton 2022-08-01 22:30:29 -05:00
itsMapleLeaf
cbd9120c34 .new.new 2022-07-31 23:43:32 -05:00
itsMapleLeaf
98d6f59fe4 make folder for djs stuff 2022-07-28 22:18:45 -05:00
itsMapleLeaf
aee31c4be2 build library/main 2022-07-28 22:18:03 -05:00
itsMapleLeaf
f2a322e4cd restore old tests + more parallel things 2022-07-28 22:15:49 -05:00
itsMapleLeaf
831bf9ea44 make a new package for helpers 2022-07-27 22:42:35 -05:00
itsMapleLeaf
0df45acba3 keep this helper for later maybe 2022-07-27 18:31:20 -05:00
itsMapleLeaf
76d50b00fa imports 2022-07-27 18:31:12 -05:00
itsMapleLeaf
528e600f1a more sensible test 2022-07-27 18:30:34 -05:00
itsMapleLeaf
42d1541697 more convenient test code 2022-07-27 18:29:59 -05:00
itsMapleLeaf
de53faa828 move generate prop combinations to helpers 2022-07-27 12:47:17 -05:00
itsMapleLeaf
83d146279a scuffed button test 2022-07-26 12:48:00 -05:00
itsMapleLeaf
91c250f63f accept children for button label 2022-07-26 12:15:48 -05:00
itsMapleLeaf
4e3f1cc7cb refactor with node classes again
node classes are great as generic containers, and extended classes are great for node identity with instanceof

also realized that the NodeFactory is a detail of ReacordElement, so I moved it and renamed it to ReacordElementConfig
2022-07-26 09:19:59 -05:00
itsMapleLeaf
67b1f45a8f move things to folders 2022-07-25 11:03:55 -05:00
itsMapleLeaf
9a96da1d34 simplify node structure + convert to message payload in core 2022-07-25 10:47:12 -05:00
itsMapleLeaf
06a8976d8e buttons 2022-07-24 20:41:25 -05:00
itsMapleLeaf
4b6de3ab5f pretty ms for funsies 2022-07-24 15:24:21 -05:00
itsMapleLeaf
35fbf93be7 trying to reduce "layers of conversion"
one problem with the current iteration of reacord is the number of conversation layers there are between internals and the adapter.

the flow is: elements -> node tree -> reacord objects -> adapter objects -> adapter renderer

so far it looks like I can reduce this to: elements -> node tree -> adapter renderer
2022-07-24 15:02:07 -05:00
itsMapleLeaf
cfd88fe110 fix test 2022-07-24 13:46:08 -05:00
itsMapleLeaf
a9b5e4c380 rename files appropriately 2022-07-24 13:42:21 -05:00
itsMapleLeaf
f9564897aa classes are fine, actually! + simplified things more 2022-07-24 13:39:55 -05:00
itsMapleLeaf
533d8a0f60 add back reconciler generic comments
dunno what happened to them lol
2022-07-24 13:39:13 -05:00
itsMapleLeaf
05c940ff52 destroying messages, placeholder for deactivate 2022-07-23 19:19:13 -05:00
itsMapleLeaf
4db32ddbbb async queue abstraction 2022-07-23 18:39:17 -05:00
itsMapleLeaf
02808b7550 split stuff up + handle immediate renders 2022-07-23 18:29:16 -05:00
itsMapleLeaf
1197d12a19 initial hacked-together draft 2022-07-23 17:46:54 -05:00
102 changed files with 3229 additions and 3690 deletions

View File

@@ -9,6 +9,7 @@ env:
TEST_BOT_TOKEN: ${{ secrets.TEST_BOT_TOKEN }}
TEST_CHANNEL_ID: ${{ secrets.TEST_CHANNEL_ID }}
TEST_GUILD_ID: ${{ secrets.TEST_GUILD_ID }}
TEST_CATEGORY_ID: ${{ secrets.TEST_CATEGORY_ID }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -20,12 +21,13 @@ jobs:
fail-fast: false
matrix:
command:
# if these run in the same process, it dies,
# if tests run in the same process, it dies,
# so we test them separate
- name: test reacord
run: pnpm -C packages/reacord test
- name: test
run: pnpm test
- name: test website
run: pnpm -C packages/website test
# the cache doesn't include cypress install, need to do it manually here
run: pnpm -C packages/website exec cypress install && pnpm -C packages/website test
- name: build
run: pnpm --recursive run build
- name: lint
@@ -36,10 +38,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2
with:
version: 7.8.0
- uses: actions/setup-node@v2
with:
# https://github.com/actions/setup-node#supported-version-syntax
node-version: "16"
- run: npm i -g pnpm@7.5.0
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: ${{ matrix.command.run }}

View File

@@ -4,20 +4,25 @@
"scripts": {
"lint": "eslint --ext js,ts,tsx .",
"lint-fix": "pnpm lint -- --fix",
"test": "vitest --coverage --no-watch",
"test-dev": "vitest --ui",
"format": "prettier --write .",
"build": "pnpm -r run build",
"start": "pnpm -C packages/website run start",
"release": "pnpm -r run build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.24.0",
"@changesets/cli": "^2.24.2",
"@itsmapleleaf/configs": "^1.1.5",
"@rushstack/eslint-patch": "^1.1.4",
"@types/eslint": "^8.4.5",
"eslint": "^8.20.0",
"@vitest/ui": "^0.21.0",
"c8": "^7.12.0",
"eslint": "^8.21.0",
"node": "^16.16.0",
"prettier": "^2.7.1",
"typescript": "^4.7.4"
"typescript": "^4.7.4",
"vitest": "^0.21.0"
},
"resolutions": {
"esbuild": "latest"

View File

@@ -0,0 +1,35 @@
export type AsyncCallback<T> = () => T
type QueueItem = {
callback: AsyncCallback<unknown>
resolve: (value: unknown) => void
reject: (error: unknown) => void
}
export class AsyncQueue {
private items: QueueItem[] = []
private running = false
append<T>(callback: AsyncCallback<T>): Promise<Awaited<T>> {
return new Promise((resolve, reject) => {
this.items.push({ callback, resolve: resolve as any, reject })
void this.run()
})
}
private async run() {
if (this.running) return
this.running = true
let item
while ((item = this.items.shift())) {
try {
item.resolve(await item.callback())
} catch (error) {
item.reject(error)
}
}
this.running = false
}
}

View File

@@ -0,0 +1,21 @@
export function generatePropCombinations<P>(values: {
[K in keyof P]: ReadonlyArray<P[K]>
}) {
return generatePropCombinationsRecursive(values) as P[]
}
function generatePropCombinationsRecursive(
value: Record<string, readonly unknown[]>,
): Array<Record<string, unknown>> {
const [key] = Object.keys(value)
if (!key) return [{}]
const { [key]: values = [], ...otherValues } = value
const result: Array<Record<string, unknown>> = []
for (const value of values) {
for (const otherValue of generatePropCombinationsRecursive(otherValues)) {
result.push({ [key]: value, ...otherValue })
}
}
return result
}

View File

@@ -0,0 +1,11 @@
{
"name": "@reacord/helpers",
"type": "module",
"private": true,
"dependencies": {
"@types/lodash-es": "^4.17.6",
"@types/node": "*",
"lodash-es": "^4.17.21",
"type-fest": "^2.18.0"
}
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.base.json"
}

View File

@@ -0,0 +1,21 @@
{
"name": "@reacord/playground",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/main.tsx"
},
"dependencies": {
"@reacord/helpers": "workspace:*",
"discord.js": "^14.1.2",
"dotenv": "^16.0.1",
"ora": "^6.1.2",
"react": "^18.2.0"
},
"devDependencies": {
"@types/node": "*",
"@types/react": "^18.0.16",
"tsx": "^3.8.0",
"typescript": "^4.7.4"
}
}

View File

@@ -0,0 +1,54 @@
import { raise } from "@reacord/helpers/raise"
import { Client, GatewayIntentBits } from "discord.js"
import * as dotenv from "dotenv"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
import { oraPromise } from "ora"
import React from "react"
import { Button, ReacordClient } from "../../reacord/src/main"
dotenv.config({
path: join(fileURLToPath(import.meta.url), "../../../../.env"),
override: true,
})
const token = process.env.TEST_BOT_TOKEN ?? raise("TEST_BOT_TOKEN not defined")
const client = new Client({ intents: [GatewayIntentBits.Guilds] })
const reacord = new ReacordClient({ token })
client.once("ready", async (client) => {
try {
await oraPromise(
client.application.commands.create({
name: "counter",
description: "counts things",
}),
"Registering commands",
)
} catch (error) {
console.error("Failed to register commands:", error)
}
})
client.on("interactionCreate", async (interaction) => {
if (
interaction.isChatInputCommand() &&
interaction.commandName === "counter"
) {
reacord.reply(interaction, <Counter />)
// reacord.reply(interaction, "test3").render("test4")
}
})
await oraPromise(client.login(token), "Logging in")
function Counter() {
const [count, setCount] = React.useState(0)
return (
<>
count: {count}
<Button label="+" onClick={() => setCount(count + 1)} />
</>
)
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.base.json"
}

View File

@@ -1,113 +0,0 @@
import type { ReactNode } from "react"
import type { ReacordInstance } from "./instance"
/**
* @category Component Event
*/
export type ComponentEvent = {
/**
* The message associated with this event.
* For example: with a button click,
* this is the message that the button is on.
* @see https://discord.com/developers/docs/resources/channel#message-object
*/
message: MessageInfo
/**
* The channel that this event occurred in.
* @see https://discord.com/developers/docs/resources/channel#channel-object
*/
channel: ChannelInfo
/**
* The user that triggered this event.
* @see https://discord.com/developers/docs/resources/user#user-object
*/
user: UserInfo
/**
* The guild that this event occurred in.
* @see https://discord.com/developers/docs/resources/guild#guild-object
*/
guild?: GuildInfo
/**
* Create a new reply to this event.
*/
reply(content?: ReactNode): ReacordInstance
/**
* Create an ephemeral reply to this event,
* shown only to the user who triggered it.
*/
ephemeralReply(content?: ReactNode): ReacordInstance
}
/**
* @category Component Event
*/
export type ChannelInfo = {
id: string
name?: string
topic?: string
nsfw?: boolean
lastMessageId?: string
ownerId?: string
parentId?: string
rateLimitPerUser?: number
}
/**
* @category Component Event
*/
export type MessageInfo = {
id: string
channelId: string
authorId: UserInfo
member?: GuildMemberInfo
content: string
timestamp: string
editedTimestamp?: string
tts: boolean
mentionEveryone: boolean
/** The IDs of mentioned users */
mentions: string[]
}
/**
* @category Component Event
*/
export type GuildInfo = {
id: string
name: string
member: GuildMemberInfo
}
/**
* @category Component Event
*/
export type GuildMemberInfo = {
id: string
nick?: string
displayName: string
avatarUrl?: string
displayAvatarUrl: string
roles: string[]
color: number
joinedAt?: string
premiumSince?: string
pending?: boolean
communicationDisabledUntil?: string
}
/**
* @category Component Event
*/
export type UserInfo = {
id: string
username: string
discriminator: string
tag: string
avatarUrl: string
accentColor?: number
}

View File

@@ -1,77 +0,0 @@
import { randomUUID } from "node:crypto"
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import type { ComponentInteraction } from "../../internal/interaction"
import type { MessageOptions } from "../../internal/message"
import { getNextActionRow } from "../../internal/message"
import { Node } from "../../internal/node.js"
import type { ComponentEvent } from "../component-event"
import type { ButtonSharedProps } from "./button-shared-props"
/**
* @category Button
*/
export type ButtonProps = ButtonSharedProps & {
/**
* The style determines the color of the button and signals intent.
* @see https://discord.com/developers/docs/interactions/message-components#button-object-button-styles
*/
style?: "primary" | "secondary" | "success" | "danger"
/**
* Happens when a user clicks the button.
*/
onClick: (event: ButtonClickEvent) => void
}
/**
* @category Button
*/
export type ButtonClickEvent = ComponentEvent
/**
* @category Button
*/
export function Button(props: ButtonProps) {
return (
<ReacordElement props={props} createNode={() => new ButtonNode(props)}>
<ReacordElement props={{}} createNode={() => new ButtonLabelNode({})}>
{props.label}
</ReacordElement>
</ReacordElement>
)
}
class ButtonNode extends Node<ButtonProps> {
private customId = randomUUID()
// this has text children, but buttons themselves shouldn't yield text
// eslint-disable-next-line class-methods-use-this
override get text() {
return ""
}
override modifyMessageOptions(options: MessageOptions): void {
getNextActionRow(options).push({
type: "button",
customId: this.customId,
style: this.props.style ?? "secondary",
disabled: this.props.disabled,
emoji: this.props.emoji,
label: this.children.findType(ButtonLabelNode)?.text,
})
}
override handleComponentInteraction(interaction: ComponentInteraction) {
if (
interaction.type === "button" &&
interaction.customId === this.customId
) {
this.props.onClick(interaction.event)
return true
}
return false
}
}
class ButtonLabelNode extends Node<{}> {}

View File

@@ -1,41 +0,0 @@
import type { ReactNode } from "react"
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { Node } from "../../internal/node.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedAuthorProps = {
name?: ReactNode
children?: ReactNode
url?: string
iconUrl?: string
}
/**
* @category Embed
*/
export function EmbedAuthor(props: EmbedAuthorProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedAuthorNode(props)}>
<ReacordElement props={{}} createNode={() => new AuthorTextNode({})}>
{props.name ?? props.children}
</ReacordElement>
</ReacordElement>
)
}
class EmbedAuthorNode extends EmbedChildNode<EmbedAuthorProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.author = {
name: this.children.findType(AuthorTextNode)?.text ?? "",
url: this.props.url,
icon_url: this.props.iconUrl,
}
}
}
class AuthorTextNode extends Node<{}> {}

View File

@@ -1,6 +0,0 @@
import { Node } from "../../internal/node.js"
import type { EmbedOptions } from "./embed-options"
export abstract class EmbedChildNode<Props> extends Node<Props> {
abstract modifyEmbedOptions(options: EmbedOptions): void
}

View File

@@ -1,46 +0,0 @@
import type { ReactNode } from "react"
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { Node } from "../../internal/node.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedFieldProps = {
name: ReactNode
value?: ReactNode
inline?: boolean
children?: ReactNode
}
/**
* @category Embed
*/
export function EmbedField(props: EmbedFieldProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedFieldNode(props)}>
<ReacordElement props={{}} createNode={() => new FieldNameNode({})}>
{props.name}
</ReacordElement>
<ReacordElement props={{}} createNode={() => new FieldValueNode({})}>
{props.value || props.children}
</ReacordElement>
</ReacordElement>
)
}
class EmbedFieldNode extends EmbedChildNode<EmbedFieldProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.fields ??= []
options.fields.push({
name: this.children.findType(FieldNameNode)?.text ?? "",
value: this.children.findType(FieldValueNode)?.text ?? "",
inline: this.props.inline,
})
}
}
class FieldNameNode extends Node<{}> {}
class FieldValueNode extends Node<{}> {}

View File

@@ -1,45 +0,0 @@
import type { ReactNode } from "react"
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { Node } from "../../internal/node.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedFooterProps = {
text?: ReactNode
children?: ReactNode
iconUrl?: string
timestamp?: string | number | Date
}
/**
* @category Embed
*/
export function EmbedFooter({ text, children, ...props }: EmbedFooterProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedFooterNode(props)}>
<ReacordElement props={{}} createNode={() => new FooterTextNode({})}>
{text ?? children}
</ReacordElement>
</ReacordElement>
)
}
class EmbedFooterNode extends EmbedChildNode<
Omit<EmbedFooterProps, "text" | "children">
> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.footer = {
text: this.children.findType(FooterTextNode)?.text ?? "",
icon_url: this.props.iconUrl,
}
options.timestamp = this.props.timestamp
? new Date(this.props.timestamp).toISOString()
: undefined
}
}
class FooterTextNode extends Node<{}> {}

View File

@@ -1,29 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedImageProps = {
url: string
}
/**
* @category Embed
*/
export function EmbedImage(props: EmbedImageProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedImageNode(props)}
/>
)
}
class EmbedImageNode extends EmbedChildNode<EmbedImageProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.image = { url: this.props.url }
}
}

View File

@@ -1,8 +0,0 @@
import type { Except, SnakeCasedPropertiesDeep } from "type-fest"
import type { EmbedProps } from "./embed"
export type EmbedOptions = SnakeCasedPropertiesDeep<
Except<EmbedProps, "timestamp" | "children"> & {
timestamp?: string
}
>

View File

@@ -1,29 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedThumbnailProps = {
url: string
}
/**
* @category Embed
*/
export function EmbedThumbnail(props: EmbedThumbnailProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedThumbnailNode(props)}
/>
)
}
class EmbedThumbnailNode extends EmbedChildNode<EmbedThumbnailProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.thumbnail = { url: this.props.url }
}
}

View File

@@ -1,36 +0,0 @@
import type { ReactNode } from "react"
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { Node } from "../../internal/node.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedTitleProps = {
children: ReactNode
url?: string
}
/**
* @category Embed
*/
export function EmbedTitle({ children, ...props }: EmbedTitleProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedTitleNode(props)}>
<ReacordElement props={{}} createNode={() => new TitleTextNode({})}>
{children}
</ReacordElement>
</ReacordElement>
)
}
class EmbedTitleNode extends EmbedChildNode<Omit<EmbedTitleProps, "children">> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.title = this.children.findType(TitleTextNode)?.text ?? ""
options.url = this.props.url
}
}
class TitleTextNode extends Node<{}> {}

View File

@@ -1,62 +0,0 @@
import React from "react"
import { snakeCaseDeep } from "../../../helpers/convert-object-property-case"
import { omit } from "../../../helpers/omit"
import { ReacordElement } from "../../internal/element.js"
import type { MessageOptions } from "../../internal/message"
import { Node } from "../../internal/node.js"
import { TextNode } from "../../internal/text-node"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
* @see https://discord.com/developers/docs/resources/channel#embed-object
*/
export type EmbedProps = {
title?: string
description?: string
url?: string
color?: number
fields?: Array<{ name: string; value: string; inline?: boolean }>
author?: { name: string; url?: string; iconUrl?: string }
thumbnail?: { url: string }
image?: { url: string }
video?: { url: string }
footer?: { text: string; iconUrl?: string }
timestamp?: string | number | Date
children?: React.ReactNode
}
/**
* @category Embed
* @see https://discord.com/developers/docs/resources/channel#embed-object
*/
export function Embed(props: EmbedProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedNode(props)}>
{props.children}
</ReacordElement>
)
}
class EmbedNode extends Node<EmbedProps> {
override modifyMessageOptions(options: MessageOptions): void {
const embed: EmbedOptions = {
...snakeCaseDeep(omit(this.props, ["children", "timestamp"])),
timestamp: this.props.timestamp
? new Date(this.props.timestamp).toISOString()
: undefined,
}
for (const child of this.children) {
if (child instanceof EmbedChildNode) {
child.modifyEmbedOptions(embed)
}
if (child instanceof TextNode) {
embed.description = (embed.description || "") + child.props
}
}
options.embeds.push(embed)
}
}

View File

@@ -1,43 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import type { MessageOptions } from "../../internal/message"
import { getNextActionRow } from "../../internal/message"
import { Node } from "../../internal/node.js"
import type { ButtonSharedProps } from "./button-shared-props"
/**
* @category Link
*/
export type LinkProps = ButtonSharedProps & {
/** The URL the link should lead to */
url: string
/** The link text */
children?: string
}
/**
* @category Link
*/
export function Link({ label, children, ...props }: LinkProps) {
return (
<ReacordElement props={props} createNode={() => new LinkNode(props)}>
<ReacordElement props={{}} createNode={() => new LinkTextNode({})}>
{label || children}
</ReacordElement>
</ReacordElement>
)
}
class LinkNode extends Node<Omit<LinkProps, "label" | "children">> {
override modifyMessageOptions(options: MessageOptions): void {
getNextActionRow(options).push({
type: "link",
disabled: this.props.disabled,
emoji: this.props.emoji,
label: this.children.findType(LinkTextNode)?.text,
url: this.props.url,
})
}
}
class LinkTextNode extends Node<{}> {}

View File

@@ -1,19 +0,0 @@
import type { MessageSelectOptionOptions } from "../../internal/message"
import { Node } from "../../internal/node"
import type { OptionProps } from "./option"
export class OptionNode extends Node<
Omit<OptionProps, "children" | "label" | "description">
> {
get options(): MessageSelectOptionOptions {
return {
label: this.children.findType(OptionLabelNode)?.text ?? this.props.value,
value: this.props.value,
description: this.children.findType(OptionDescriptionNode)?.text,
emoji: this.props.emoji,
}
}
}
export class OptionLabelNode extends Node<{}> {}
export class OptionDescriptionNode extends Node<{}> {}

View File

@@ -1,19 +0,0 @@
import type { ReactNode } from "react"
/**
* Represents an interactive message, which can later be replaced or deleted.
* @category Core
*/
export type ReacordInstance = {
/** Render some JSX to this instance (edits the message) */
render: (content: ReactNode) => void
/** Remove this message */
destroy: () => void
/**
* Same as destroy, but keeps the message and disables the components on it.
* This prevents it from listening to user interactions.
*/
deactivate: () => void
}

View File

@@ -1,389 +0,0 @@
/* eslint-disable class-methods-use-this */
import * as Discord from "discord.js"
import type { ReactNode } from "react"
import type { Except } from "type-fest"
import { pick } from "../../helpers/pick"
import { pruneNullishValues } from "../../helpers/prune-nullish-values"
import { raise } from "../../helpers/raise"
import type { ComponentInteraction } from "../internal/interaction"
import type {
Message,
MessageButtonOptions,
MessageOptions,
} from "../internal/message"
import { ChannelMessageRenderer } from "../internal/renderers/channel-message-renderer"
import { InteractionReplyRenderer } from "../internal/renderers/interaction-reply-renderer"
import type {
ChannelInfo,
GuildInfo,
GuildMemberInfo,
MessageInfo,
UserInfo,
} from "./component-event"
import type { ReacordInstance } from "./instance"
import type { ReacordConfig } from "./reacord"
import { Reacord } from "./reacord"
/**
* The Reacord adapter for Discord.js.
* @category Core
*/
export class ReacordDiscordJs extends Reacord {
constructor(private client: Discord.Client, config: ReacordConfig = {}) {
super(config)
client.on("interactionCreate", (interaction) => {
if (interaction.isButton() || interaction.isSelectMenu()) {
this.handleComponentInteraction(
this.createReacordComponentInteraction(interaction),
)
}
})
}
/**
* Sends a message to a channel.
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
override send(
channelId: string,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createChannelRenderer(channelId),
initialContent,
)
}
/**
* Sends a message as a reply to a command interaction.
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
override reply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createInteractionReplyRenderer(interaction),
initialContent,
)
}
/**
* Sends an ephemeral message as a reply to a command interaction.
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
override ephemeralReply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createEphemeralInteractionReplyRenderer(interaction),
initialContent,
)
}
private createChannelRenderer(channelId: string) {
return new ChannelMessageRenderer({
send: async (options) => {
const channel =
this.client.channels.cache.get(channelId) ??
(await this.client.channels.fetch(channelId)) ??
raise(`Channel ${channelId} not found`)
if (!channel.isTextBased()) {
raise(`Channel ${channelId} is not a text channel`)
}
const message = await channel.send(getDiscordMessageOptions(options))
return createReacordMessage(message)
},
})
}
private createInteractionReplyRenderer(
interaction:
| Discord.CommandInteraction
| Discord.MessageComponentInteraction,
) {
return new InteractionReplyRenderer({
type: "command",
id: interaction.id,
reply: async (options) => {
const message = await interaction.reply({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
followUp: async (options) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
})
}
private createEphemeralInteractionReplyRenderer(
interaction:
| Discord.CommandInteraction
| Discord.MessageComponentInteraction,
) {
return new InteractionReplyRenderer({
type: "command",
id: interaction.id,
reply: async (options) => {
await interaction.reply({
...getDiscordMessageOptions(options),
ephemeral: true,
})
return createEphemeralReacordMessage()
},
followUp: async (options) => {
await interaction.followUp({
...getDiscordMessageOptions(options),
ephemeral: true,
})
return createEphemeralReacordMessage()
},
})
}
private createReacordComponentInteraction(
interaction: Discord.MessageComponentInteraction,
): ComponentInteraction {
// todo please dear god clean this up
const channel: ChannelInfo = interaction.channel
? {
...pruneNullishValues(
pick(interaction.channel, [
"topic",
"nsfw",
"lastMessageId",
"ownerId",
"parentId",
"rateLimitPerUser",
]),
),
id: interaction.channelId,
}
: raise("Non-channel interactions are not supported")
const message: MessageInfo =
interaction.message instanceof Discord.Message
? {
...pick(interaction.message, [
"id",
"channelId",
"authorId",
"content",
"tts",
"mentionEveryone",
]),
timestamp: new Date(
interaction.message.createdTimestamp,
).toISOString(),
editedTimestamp: interaction.message.editedTimestamp
? new Date(interaction.message.editedTimestamp).toISOString()
: undefined,
mentions: interaction.message.mentions.users.map((u) => u.id),
}
: raise("Message not found")
const member: GuildMemberInfo | undefined =
interaction.member instanceof Discord.GuildMember
? {
...pruneNullishValues(
pick(interaction.member, [
"id",
"nick",
"displayName",
"avatarUrl",
"displayAvatarUrl",
"color",
"pending",
]),
),
displayName: interaction.member.displayName,
roles: [...interaction.member.roles.cache.map((role) => role.id)],
joinedAt: interaction.member.joinedAt?.toISOString(),
premiumSince: interaction.member.premiumSince?.toISOString(),
communicationDisabledUntil:
interaction.member.communicationDisabledUntil?.toISOString(),
}
: undefined
const guild: GuildInfo | undefined = interaction.guild
? {
...pruneNullishValues(pick(interaction.guild, ["id", "name"])),
member: member ?? raise("unexpected: member is undefined"),
}
: undefined
const user: UserInfo = {
...pruneNullishValues(
pick(interaction.user, ["id", "username", "discriminator", "tag"]),
),
avatarUrl: interaction.user.avatarURL()!,
accentColor: interaction.user.accentColor ?? undefined,
}
const baseProps: Except<ComponentInteraction, "type"> = {
id: interaction.id,
customId: interaction.customId,
update: async (options: MessageOptions) => {
await interaction.update(getDiscordMessageOptions(options))
},
deferUpdate: async () => {
if (interaction.replied || interaction.deferred) return
await interaction.deferUpdate()
},
reply: async (options) => {
const message = await interaction.reply({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
followUp: async (options) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
event: {
channel,
message,
user,
guild,
reply: (content?: ReactNode) =>
this.createInstance(
this.createInteractionReplyRenderer(interaction),
content,
),
ephemeralReply: (content: ReactNode) =>
this.createInstance(
this.createEphemeralInteractionReplyRenderer(interaction),
content,
),
},
}
if (interaction.isButton()) {
return {
...baseProps,
type: "button",
}
}
if (interaction.isSelectMenu()) {
return {
...baseProps,
type: "select",
event: {
...baseProps.event,
values: interaction.values,
},
}
}
raise(`Unsupported component interaction type: ${interaction.type}`)
}
}
function createReacordMessage(message: Discord.Message): Message {
return {
edit: async (options) => {
await message.edit(getDiscordMessageOptions(options))
},
delete: async () => {
await message.delete()
},
}
}
function createEphemeralReacordMessage(): Message {
return {
edit: () => {
console.warn("Ephemeral messages can't be edited")
return Promise.resolve()
},
delete: () => {
console.warn("Ephemeral messages can't be deleted")
return Promise.resolve()
},
}
}
function convertButtonStyleToEnum(style: MessageButtonOptions["style"]) {
const styleMap = {
primary: Discord.ButtonStyle.Primary,
secondary: Discord.ButtonStyle.Secondary,
success: Discord.ButtonStyle.Success,
danger: Discord.ButtonStyle.Danger,
} as const
return styleMap[style ?? "secondary"]
}
// TODO: this could be a part of the core library,
// and also handle some edge cases, e.g. empty messages
function getDiscordMessageOptions(reacordOptions: MessageOptions) {
const options = {
// eslint-disable-next-line unicorn/no-null
content: reacordOptions.content || null,
embeds: reacordOptions.embeds,
components: reacordOptions.actionRows.map((row) => ({
type: Discord.ComponentType.ActionRow,
components: row.map(
(component): Discord.MessageActionRowComponentData => {
if (component.type === "button") {
return {
type: Discord.ComponentType.Button,
customId: component.customId,
label: component.label ?? "",
style: convertButtonStyleToEnum(component.style),
disabled: component.disabled,
emoji: component.emoji,
}
}
if (component.type === "link") {
return {
type: Discord.ComponentType.Button,
url: component.url,
label: component.label ?? "",
style: Discord.ButtonStyle.Link,
disabled: component.disabled,
emoji: component.emoji,
}
}
if (component.type === "select") {
return {
...component,
type: Discord.ComponentType.SelectMenu,
options: component.options.map((option) => ({
...option,
default: component.values?.includes(option.value),
})),
}
}
raise(`Unsupported component type: ${(component as any).type}`)
},
),
})),
}
if (!options.content && !options.embeds?.length) {
options.content = "_ _"
}
return options
}

View File

@@ -1,91 +0,0 @@
import type { ReactNode } from "react"
import React from "react"
import type { ComponentInteraction } from "../internal/interaction"
import { reconciler } from "../internal/reconciler.js"
import type { Renderer } from "../internal/renderers/renderer"
import type { ReacordInstance } from "./instance"
import { InstanceProvider } from "./instance-context"
/**
* @category Core
*/
export type ReacordConfig = {
/**
* The max number of active instances.
* When this limit is exceeded, the oldest instances will be disabled.
*/
maxInstances?: number
}
/**
* The main Reacord class that other Reacord adapters should extend.
* Only use this directly if you're making [a custom adapter](/guides/custom-adapters).
*/
export abstract class Reacord {
private renderers: Renderer[] = []
constructor(private readonly config: ReacordConfig = {}) {}
abstract send(...args: unknown[]): ReacordInstance
abstract reply(...args: unknown[]): ReacordInstance
abstract ephemeralReply(...args: unknown[]): ReacordInstance
protected handleComponentInteraction(interaction: ComponentInteraction) {
for (const renderer of this.renderers) {
if (renderer.handleComponentInteraction(interaction)) return
}
}
private get maxInstances() {
return this.config.maxInstances ?? 50
}
protected createInstance(renderer: Renderer, initialContent?: ReactNode) {
if (this.renderers.length > this.maxInstances) {
this.deactivate(this.renderers[0]!)
}
this.renderers.push(renderer)
const container = reconciler.createContainer(
renderer,
0,
// eslint-disable-next-line unicorn/no-null
null,
false,
// eslint-disable-next-line unicorn/no-null
null,
"reacord",
() => {},
// eslint-disable-next-line unicorn/no-null
null,
)
const instance: ReacordInstance = {
render: (content: ReactNode) => {
reconciler.updateContainer(
<InstanceProvider value={instance}>{content}</InstanceProvider>,
container,
)
},
deactivate: () => {
this.deactivate(renderer)
},
destroy: () => {
this.renderers = this.renderers.filter((it) => it !== renderer)
renderer.destroy()
},
}
if (initialContent !== undefined) {
instance.render(initialContent)
}
return instance
}
private deactivate(renderer: Renderer) {
this.renderers = this.renderers.filter((it) => it !== renderer)
renderer.deactivate()
}
}

View File

@@ -1,5 +0,0 @@
import type { Message, MessageOptions } from "./message"
export type Channel = {
send(message: MessageOptions): Promise<Message>
}

View File

@@ -1,37 +0,0 @@
export class Container<T> {
private items: T[] = []
add(...items: T[]) {
this.items.push(...items)
}
addBefore(item: T, before: T) {
let index = this.items.indexOf(before)
if (index === -1) {
index = this.items.length
}
this.items.splice(index, 0, item)
}
remove(toRemove: T) {
this.items = this.items.filter((item) => item !== toRemove)
}
clear() {
this.items = []
}
find(predicate: (item: T) => boolean): T | undefined {
return this.items.find(predicate)
}
findType<U extends T>(type: new (...args: any[]) => U): U | undefined {
for (const item of this.items) {
if (item instanceof type) return item
}
}
[Symbol.iterator]() {
return this.items[Symbol.iterator]()
}
}

View File

@@ -1,35 +0,0 @@
import type { ComponentEvent } from "../core/component-event"
import type { ButtonClickEvent, SelectChangeEvent } from "../main"
import type { Message, MessageOptions } from "./message"
export type Interaction = CommandInteraction | ComponentInteraction
export type ComponentInteraction = ButtonInteraction | SelectInteraction
export type CommandInteraction = BaseInteraction<"command">
export type ButtonInteraction = BaseComponentInteraction<
"button",
ButtonClickEvent
>
export type SelectInteraction = BaseComponentInteraction<
"select",
SelectChangeEvent
>
export type BaseInteraction<Type extends string> = {
type: Type
id: string
reply(messageOptions: MessageOptions): Promise<Message>
followUp(messageOptions: MessageOptions): Promise<Message>
}
export type BaseComponentInteraction<
Type extends string,
Event extends ComponentEvent,
> = BaseInteraction<Type> & {
event: Event
customId: string
update(options: MessageOptions): Promise<void>
deferUpdate(): Promise<void>
}

View File

@@ -1,24 +0,0 @@
export class LimitedCollection<T> {
private items: T[] = []
constructor(private readonly size: number) {}
add(item: T) {
if (this.items.length >= this.size) {
this.items.shift()
}
this.items.push(item)
}
has(item: T) {
return this.items.includes(item)
}
values(): readonly T[] {
return this.items
}
[Symbol.iterator]() {
return this.items[Symbol.iterator]()
}
}

View File

@@ -1,65 +0,0 @@
import type { Except } from "type-fest"
import { last } from "../../helpers/last"
import type { EmbedOptions } from "../core/components/embed-options"
import type { SelectProps } from "../core/components/select"
export type MessageOptions = {
content: string
embeds: EmbedOptions[]
actionRows: ActionRow[]
}
export type ActionRow = ActionRowItem[]
export type ActionRowItem =
| MessageButtonOptions
| MessageLinkOptions
| MessageSelectOptions
export type MessageButtonOptions = {
type: "button"
customId: string
label?: string
style?: "primary" | "secondary" | "success" | "danger"
disabled?: boolean
emoji?: string
}
export type MessageLinkOptions = {
type: "link"
url: string
label?: string
emoji?: string
disabled?: boolean
}
export type MessageSelectOptions = Except<SelectProps, "children" | "value"> & {
type: "select"
customId: string
options: MessageSelectOptionOptions[]
}
export type MessageSelectOptionOptions = {
label: string
value: string
description?: string
emoji?: string
}
export type Message = {
edit(options: MessageOptions): Promise<void>
delete(): Promise<void>
}
export function getNextActionRow(options: MessageOptions): ActionRow {
let actionRow = last(options.actionRows)
if (
actionRow == undefined ||
actionRow.length >= 5 ||
actionRow[0]?.type === "select"
) {
actionRow = []
options.actionRows.push(actionRow)
}
return actionRow
}

View File

@@ -1,20 +0,0 @@
/* eslint-disable class-methods-use-this */
import { Container } from "./container.js"
import type { ComponentInteraction } from "./interaction"
import type { MessageOptions } from "./message"
export abstract class Node<Props> {
readonly children = new Container<Node<unknown>>()
constructor(public props: Props) {}
modifyMessageOptions(options: MessageOptions) {}
handleComponentInteraction(interaction: ComponentInteraction): boolean {
return false
}
get text(): string {
return [...this.children].map((child) => child.text).join("")
}
}

View File

@@ -1,13 +0,0 @@
import type { Channel } from "../channel"
import type { Message, MessageOptions } from "../message"
import { Renderer } from "./renderer"
export class ChannelMessageRenderer extends Renderer {
constructor(private channel: Channel) {
super()
}
protected createMessage(options: MessageOptions): Promise<Message> {
return this.channel.send(options)
}
}

View File

@@ -1,22 +0,0 @@
import type { Interaction } from "../interaction"
import type { Message, MessageOptions } from "../message"
import { Renderer } from "./renderer"
// keep track of interaction ids which have replies,
// so we know whether to call reply() or followUp()
const repliedInteractionIds = new Set<string>()
export class InteractionReplyRenderer extends Renderer {
constructor(private interaction: Interaction) {
super()
}
protected createMessage(options: MessageOptions): Promise<Message> {
if (repliedInteractionIds.has(this.interaction.id)) {
return this.interaction.followUp(options)
}
repliedInteractionIds.add(this.interaction.id)
return this.interaction.reply(options)
}
}

View File

@@ -1,119 +0,0 @@
import { Subject } from "rxjs"
import { concatMap } from "rxjs/operators"
import { Container } from "../container.js"
import type { ComponentInteraction } from "../interaction"
import type { Message, MessageOptions } from "../message"
import type { Node } from "../node.js"
type UpdatePayload =
| { action: "update" | "deactivate"; options: MessageOptions }
| { action: "deferUpdate"; interaction: ComponentInteraction }
| { action: "destroy" }
export abstract class Renderer {
readonly nodes = new Container<Node<unknown>>()
private componentInteraction?: ComponentInteraction
private message?: Message
private active = true
private updates = new Subject<UpdatePayload>()
private updateSubscription = this.updates
.pipe(concatMap((payload) => this.updateMessage(payload)))
.subscribe({ error: console.error })
render() {
if (!this.active) {
console.warn("Attempted to update a deactivated message")
return
}
this.updates.next({
options: this.getMessageOptions(),
action: "update",
})
}
deactivate() {
this.active = false
this.updates.next({
options: this.getMessageOptions(),
action: "deactivate",
})
}
destroy() {
this.active = false
this.updates.next({ action: "destroy" })
}
handleComponentInteraction(interaction: ComponentInteraction) {
this.componentInteraction = interaction
setTimeout(() => {
this.updates.next({ action: "deferUpdate", interaction })
}, 500)
for (const node of this.nodes) {
if (node.handleComponentInteraction(interaction)) {
return true
}
}
}
protected abstract createMessage(options: MessageOptions): Promise<Message>
private getMessageOptions(): MessageOptions {
const options: MessageOptions = {
content: "",
embeds: [],
actionRows: [],
}
for (const node of this.nodes) {
node.modifyMessageOptions(options)
}
return options
}
private async updateMessage(payload: UpdatePayload) {
if (payload.action === "destroy") {
this.updateSubscription.unsubscribe()
await this.message?.delete()
return
}
if (payload.action === "deactivate") {
this.updateSubscription.unsubscribe()
await this.message?.edit({
...payload.options,
actionRows: payload.options.actionRows.map((row) =>
row.map((component) => ({
...component,
disabled: true,
})),
),
})
return
}
if (payload.action === "deferUpdate") {
await payload.interaction.deferUpdate()
return
}
if (this.componentInteraction) {
const promise = this.componentInteraction.update(payload.options)
this.componentInteraction = undefined
await promise
return
}
if (this.message) {
await this.message.edit(payload.options)
return
}
this.message = await this.createMessage(payload.options)
}
}

View File

@@ -1,12 +0,0 @@
import type { MessageOptions } from "./message"
import { Node } from "./node.js"
export class TextNode extends Node<string> {
override modifyMessageOptions(options: MessageOptions) {
options.content = options.content + this.props
}
override get text() {
return this.props
}
}

View File

@@ -1,20 +0,0 @@
export class Timeout {
private timeoutId?: NodeJS.Timeout
constructor(
private readonly time: number,
private readonly callback: () => void,
) {}
run() {
this.cancel()
this.timeoutId = setTimeout(this.callback, this.time)
}
cancel() {
if (this.timeoutId) {
clearTimeout(this.timeoutId)
this.timeoutId = undefined
}
}
}

View File

@@ -1,18 +0,0 @@
export * from "./core/component-event"
export * from "./core/components/action-row"
export * from "./core/components/button"
export * from "./core/components/button-shared-props"
export * from "./core/components/embed"
export * from "./core/components/embed-author"
export * from "./core/components/embed-field"
export * from "./core/components/embed-footer"
export * from "./core/components/embed-image"
export * from "./core/components/embed-thumbnail"
export * from "./core/components/embed-title"
export * from "./core/components/link"
export * from "./core/components/option"
export * from "./core/components/select"
export * from "./core/instance"
export { useInstance } from "./core/instance-context"
export * from "./core/reacord"
export * from "./core/reacord-discord-js"

View File

@@ -36,18 +36,28 @@
}
},
"scripts": {
"build": "cp ../../README.md . && cp ../../LICENSE . && tsup library/main.ts --target node16 --format cjs,esm --dts --sourcemap",
"build-watch": "pnpm build -- --watch",
"test": "vitest --coverage --no-watch",
"test-dev": "vitest",
"test-manual": "nodemon --exec tsx --ext ts,tsx ./scripts/discordjs-manual-test.tsx",
"typecheck": "tsc --noEmit",
"release": "bash scripts/release.sh"
"build": "cp ../../README.md . && cp ../../LICENSE . && tsx scripts/generate-exports.ts && tsup",
"build-watch": "pnpm build --watch",
"test-manual": "tsx watch ./scripts/manual-test.tsx",
"typecheck": "tsc --noEmit"
},
"tsup": {
"entry": [
"src/main.ts"
],
"sourcemap": true,
"target": "node16",
"format": [
"cjs",
"esm"
],
"dts": true
},
"dependencies": {
"@types/node": "*",
"@types/react": "*",
"@types/react-reconciler": "^0.28.0",
"@types/react-reconciler": "*",
"discord-api-types": "^0.37.1",
"react-reconciler": "^0.29.0",
"rxjs": "^7.5.6"
},
@@ -61,21 +71,24 @@
}
},
"devDependencies": {
"@reacord/helpers": "workspace:*",
"@types/lodash-es": "^4.17.6",
"c8": "^7.12.0",
"discord.js": "^14.0.3",
"@types/prettier": "^2.7.0",
"date-fns": "^2.29.1",
"discord.js": "^14.1.2",
"dotenv": "^16.0.1",
"lodash-es": "^4.17.21",
"nodemon": "^2.0.19",
"ora": "^6.1.2",
"prettier": "^2.7.1",
"pretty-ms": "^8.0.0",
"react": "^18.2.0",
"release-it": "^15.1.3",
"tsup": "^6.1.3",
"release-it": "^15.2.0",
"ts-morph": "^15.1.0",
"tsup": "^6.2.1",
"tsx": "^3.8.0",
"type-fest": "^2.17.0",
"typescript": "^4.7.4",
"vitest": "^0.18.1"
"type-fest": "^2.18.0",
"typescript": "^4.7.4"
},
"resolutions": {
"esbuild": "latest"

View File

@@ -1,139 +0,0 @@
import type { TextChannel } from "discord.js"
import { ChannelType, Client, IntentsBitField } from "discord.js"
import "dotenv/config"
import { kebabCase } from "lodash-es"
import * as React from "react"
import { useState } from "react"
import {
Button,
Link,
Option,
ReacordDiscordJs,
Select,
useInstance,
} from "../library/main"
const client = new Client({ intents: IntentsBitField.Flags.Guilds })
const reacord = new ReacordDiscordJs(client)
await client.login(process.env.TEST_BOT_TOKEN)
const guild = await client.guilds.fetch(process.env.TEST_GUILD_ID!)
const category = await guild.channels.fetch(process.env.TEST_CATEGORY_ID!)
if (category?.type !== ChannelType.GuildCategory) {
throw new Error(
`channel ${process.env.TEST_CATEGORY_ID} is not a guild category. received ${category?.type}`,
)
}
for (const [, channel] of category.children.cache) {
await channel.delete()
}
let prefix = 0
const createTest = async (
name: string,
block: (channel: TextChannel) => void | Promise<unknown>,
) => {
prefix += 1
const channel = await category.children.create({
type: ChannelType.GuildText,
name: `${String(prefix).padStart(3, "0")}-${kebabCase(name)}`,
})
await block(channel)
}
await createTest("basic", (channel) => {
reacord.send(channel.id, "Hello, world!")
})
await createTest("counter", (channel) => {
const Counter = () => {
const [count, setCount] = React.useState(0)
return (
<>
count: {count}
<Button
style="primary"
emoji=""
onClick={() => setCount(count + 1)}
/>
<Button
style="primary"
emoji=""
onClick={() => setCount(count - 1)}
/>
<Button label="reset" onClick={() => setCount(0)} />
</>
)
}
reacord.send(channel.id, <Counter />)
})
await createTest("select", (channel) => {
function FruitSelect({ onConfirm }: { onConfirm: (choice: string) => void }) {
const [value, setValue] = useState<string>()
return (
<>
<Select
placeholder="choose a fruit"
value={value}
onChangeValue={setValue}
>
<Option value="🍎" emoji="🍎" label="apple" description="it red" />
<Option value="🍌" emoji="🍌" label="banana" description="bnanbna" />
<Option value="🍒" emoji="🍒" label="cherry" description="heh" />
</Select>
<Button
label="confirm"
disabled={value == undefined}
onClick={() => {
if (value) onConfirm(value)
}}
/>
</>
)
}
const instance = reacord.send(
channel.id,
<FruitSelect
onConfirm={(value) => {
instance.render(`you chose ${value}`)
instance.deactivate()
}}
/>,
)
})
await createTest("ephemeral button", (channel) => {
reacord.send(
channel.id,
<>
<Button
label="public clic"
onClick={(event) =>
event.reply(`${event.guild?.member.displayName} clic`)
}
/>
<Button
label="clic"
onClick={(event) => event.ephemeralReply("you clic")}
/>
</>,
)
})
await createTest("delete this", (channel) => {
function DeleteThis() {
const instance = useInstance()
return <Button label="delete this" onClick={() => instance.destroy()} />
}
reacord.send(channel.id, <DeleteThis />)
})
await createTest("link", (channel) => {
reacord.send(channel.id, <Link label="hi" url="https://mapleleaf.dev" />)
})

View File

@@ -0,0 +1,62 @@
import { writeFile } from "node:fs/promises"
import { join, relative } from "node:path/posix"
import prettier from "prettier"
import { Node, Project, SyntaxKind } from "ts-morph"
function isDeclarationPublic(declaration: Node) {
if (!Node.isJSDocable(declaration)) return false
const jsDocTags = new Set(
declaration
.getJsDocs()
.flatMap((doc) => doc.getTags())
.map((tag) => tag.getTagName()),
)
return jsDocTags.has("category") && !jsDocTags.has("private")
}
const project = new Project()
project.addSourceFilesAtPaths(["src/**/*.{ts,tsx}", "!src/main.ts"])
const exportLines = project
.getSourceFiles()
.map((file) => {
const importPath = relative(
"src",
join(file.getDirectoryPath(), file.getBaseNameWithoutExtension()),
)
const exports = file.getExportedDeclarations()
const exportNames = [...exports].flatMap(([name, [declaration]]) => {
if (!declaration) return []
if (!isDeclarationPublic(declaration)) return []
if (
declaration.isKind(SyntaxKind.TypeAliasDeclaration) ||
declaration.isKind(SyntaxKind.InterfaceDeclaration)
) {
return `type ${name}`
}
return name
})
return { importPath, exportNames }
})
.filter(({ exportNames }) => exportNames.length > 0)
.map(({ importPath, exportNames }) => {
return `export { ${exportNames.join(", ")} } from "./${importPath}"`
})
const resolvedConfig = await prettier.resolveConfig("src/main.ts")
if (!resolvedConfig) {
throw new Error("Could not find prettier config")
}
await writeFile(
"src/main.ts",
prettier.format(exportLines.join(";"), {
...resolvedConfig,
parser: "typescript",
}),
)

View File

@@ -0,0 +1,13 @@
import type { ClientOptions } from "discord.js"
import { Client } from "discord.js"
import { once } from "node:events"
export async function createDiscordClient(
token: string,
options: ClientOptions,
) {
const client = new Client(options)
await client.login(token)
const [readyClient] = await once(client, "ready")
return readyClient
}

View File

@@ -0,0 +1,28 @@
export {
type ReacordConfig,
type InteractionInfo,
ReacordClient,
} from "./reacord-client"
export { type ReacordInstance } from "./reacord-instance"
export { ActionRow, type ActionRowProps } from "./react/action-row"
export { type ButtonSharedProps } from "./react/button-shared-props"
export { Button, type ButtonProps, type ButtonClickEvent } from "./react/button"
export { type ComponentEvent } from "./react/component-event"
export { EmbedAuthor, type EmbedAuthorProps } from "./react/embed-author"
export { EmbedField, type EmbedFieldProps } from "./react/embed-field"
export { EmbedFooter, type EmbedFooterProps } from "./react/embed-footer"
export { EmbedImage, type EmbedImageProps } from "./react/embed-image"
export {
EmbedThumbnail,
type EmbedThumbnailProps,
} from "./react/embed-thumbnail"
export { EmbedTitle, type EmbedTitleProps } from "./react/embed-title"
export { Embed, type EmbedProps } from "./react/embed"
export { useInstance } from "./react/instance-context"
export { Link, type LinkProps } from "./react/link"
export { Option, type OptionProps } from "./react/option"
export {
Select,
type SelectProps,
type SelectChangeEvent,
} from "./react/select"

View File

@@ -0,0 +1,257 @@
import type {
APIActionRowComponent,
APIButtonComponent,
APIEmbed,
APISelectMenuComponent,
APISelectMenuOption,
} from "discord-api-types/v10"
import { ButtonStyle, ComponentType } from "discord-api-types/v10"
import type { Node } from "./node"
import { TextNode } from "./node"
import { ActionRowNode } from "./react/action-row"
import type { ButtonProps } from "./react/button"
import { ButtonNode } from "./react/button"
import { EmbedNode } from "./react/embed"
import { EmbedAuthorNode } from "./react/embed-author"
import {
EmbedFieldNameNode,
EmbedFieldNode,
EmbedFieldValueNode,
} from "./react/embed-field"
import { EmbedFooterNode } from "./react/embed-footer"
import { EmbedImageNode } from "./react/embed-image"
import { EmbedThumbnailNode } from "./react/embed-thumbnail"
import { EmbedTitleNode } from "./react/embed-title"
import { LinkNode } from "./react/link"
import {
OptionDescriptionNode,
OptionLabelNode,
OptionNode,
} from "./react/option"
import { SelectNode } from "./react/select"
export type MessageUpdatePayload = {
content: string | null
embeds: APIEmbed[]
components: Array<
APIActionRowComponent<APIButtonComponent | APISelectMenuComponent>
>
}
export function makeMessageUpdatePayload(root: Node): MessageUpdatePayload {
return {
// eslint-disable-next-line unicorn/no-null
content: root.extractText() || null,
embeds: makeEmbeds(root),
components: makeActionRows(root),
}
}
function makeEmbeds(root: Node) {
const embeds: APIEmbed[] = []
for (const node of root.children) {
if (node instanceof EmbedNode) {
const { props, children } = node
const embed: APIEmbed = {
author: props.author && {
name: props.author.name,
icon_url: props.author.iconUrl,
url: props.author.url,
},
color: props.color,
description: props.description,
fields: props.fields?.map(({ name, value, inline }) => ({
name,
value,
inline,
})),
footer: props.footer && {
text: props.footer.text,
icon_url: props.footer.iconUrl,
},
image: props.image,
thumbnail: props.thumbnail,
title: props.title,
url: props.url,
video: props.video,
}
if (props.timestamp !== undefined) {
embed.timestamp = normalizeDatePropToISOString(props.timestamp)
}
applyEmbedChildren(embed, children)
embeds.push(embed)
}
}
return embeds
}
function applyEmbedChildren(embed: APIEmbed, children: Node[]) {
for (const child of children) {
if (child instanceof EmbedAuthorNode) {
embed.author = {
name: child.extractText(),
icon_url: child.props.iconUrl,
url: child.props.url,
}
}
if (child instanceof EmbedFieldNode) {
embed.fields ??= []
embed.fields.push({
name: child.findInstanceOf(EmbedFieldNameNode)?.extractText() ?? "",
value:
child.findInstanceOf(EmbedFieldValueNode)?.extractText() || "_ _", // can't send an empty string
inline: child.props.inline,
})
}
if (child instanceof EmbedFooterNode) {
embed.footer = {
text: child.extractText(),
icon_url: child.props.iconUrl,
}
if (child.props.timestamp != undefined) {
embed.timestamp = normalizeDatePropToISOString(child.props.timestamp)
}
}
if (child instanceof EmbedImageNode) {
embed.image = { url: child.props.url }
}
if (child instanceof EmbedThumbnailNode) {
embed.thumbnail = { url: child.props.url }
}
if (child instanceof EmbedTitleNode) {
embed.title = child.extractText()
embed.url = child.props.url
}
if (child instanceof EmbedNode) {
applyEmbedChildren(embed, child.children)
}
if (child instanceof TextNode) {
embed.description ??= ""
embed.description += child.props.text
}
}
}
function normalizeDatePropToISOString(value: string | number | Date) {
return value instanceof Date
? value.toISOString()
: new Date(value).toISOString()
}
function makeActionRows(root: Node) {
const actionRows: Array<
APIActionRowComponent<APIButtonComponent | APISelectMenuComponent>
> = []
function getNextActionRow() {
let currentRow = actionRows[actionRows.length - 1]
if (
!currentRow ||
currentRow.components.length >= 5 ||
currentRow.components[0]?.type === ComponentType.SelectMenu
) {
currentRow = {
type: ComponentType.ActionRow,
components: [],
}
actionRows.push(currentRow)
}
return currentRow
}
for (const node of root.children) {
if (node instanceof ButtonNode) {
getNextActionRow().components.push({
type: ComponentType.Button,
custom_id: node.customId,
label: node.extractText(Number.POSITIVE_INFINITY),
emoji: node.props.emoji ? { name: node.props.emoji } : undefined,
style: translateButtonStyle(node.props.style ?? "secondary"),
disabled: node.props.disabled,
})
}
if (node instanceof LinkNode) {
getNextActionRow().components.push({
type: ComponentType.Button,
label: node.extractText(Number.POSITIVE_INFINITY),
url: node.props.url,
style: ButtonStyle.Link,
disabled: node.props.disabled,
})
}
if (node instanceof SelectNode) {
const actionRow: APIActionRowComponent<APISelectMenuComponent> = {
type: ComponentType.ActionRow,
components: [],
}
actionRows.push(actionRow)
let selectedValues: string[] = []
if (node.props.multiple && node.props.values) {
selectedValues = node.props.values ?? []
}
if (!node.props.multiple && node.props.value != undefined) {
selectedValues = [node.props.value]
}
const options = [...node.children]
.flatMap((child) => (child instanceof OptionNode ? child : []))
.map<APISelectMenuOption>((child) => ({
label:
child.findInstanceOf(OptionLabelNode)?.extractText() ||
child.props.value,
description: child
.findInstanceOf(OptionDescriptionNode)
?.extractText(),
value: child.props.value,
default: selectedValues.includes(child.props.value),
emoji: { name: child.props.emoji },
}))
const select: APISelectMenuComponent = {
type: ComponentType.SelectMenu,
custom_id: node.customId,
options,
disabled: node.props.disabled,
}
if (node.props.multiple) {
select.min_values = node.props.minValues
select.max_values = node.props.maxValues
}
actionRow.components.push(select)
}
if (node instanceof ActionRowNode) {
actionRows.push(...makeActionRows(node))
}
}
return actionRows
}
function translateButtonStyle(style: NonNullable<ButtonProps["style"]>) {
const styleMap = {
primary: ButtonStyle.Primary,
secondary: ButtonStyle.Secondary,
danger: ButtonStyle.Danger,
success: ButtonStyle.Success,
} as const
return styleMap[style]
}

View File

@@ -0,0 +1,57 @@
export class Node<Props = unknown> {
readonly children: Node[] = []
constructor(public props: Props) {}
clear() {
this.children.splice(0)
}
add(...nodes: Node[]) {
this.children.push(...nodes)
}
remove(node: Node) {
const index = this.children.indexOf(node)
if (index !== -1) this.children.splice(index, 1)
}
insertBefore(node: Node, beforeNode: Node) {
const index = this.children.indexOf(beforeNode)
if (index !== -1) this.children.splice(index, 0, node)
}
replace(oldNode: Node, newNode: Node) {
const index = this.children.indexOf(oldNode)
if (index !== -1) this.children[index] = newNode
}
clone(): this {
const cloned: this = new (this.constructor as any)()
cloned.add(...this.children.map((child) => child.clone()))
return cloned
}
*walk(): Generator<Node> {
yield this
for (const child of this.children) {
yield* child.walk()
}
}
findInstanceOf<T extends Node>(
cls: new (...args: any[]) => T,
): T | undefined {
for (const child of this.children) {
if (child instanceof cls) return child
}
}
extractText(depth = 1): string {
if (this instanceof TextNode) return this.props.text
if (depth <= 0) return ""
return this.children.map((child) => child.extractText(depth - 1)).join("")
}
}
export class TextNode extends Node<{ text: string }> {}

View File

@@ -0,0 +1,181 @@
import type { APIInteraction, Client } from "discord.js"
import {
GatewayDispatchEvents,
GatewayIntentBits,
InteractionResponseType,
InteractionType,
Routes,
} from "discord.js"
import * as React from "react"
import { createDiscordClient } from "./create-discord-client"
import type { ReacordInstance } from "./reacord-instance"
import { ReacordInstancePrivate } from "./reacord-instance"
import { InstanceProvider } from "./react/instance-context"
import type { Renderer } from "./renderer"
import {
ChannelMessageRenderer,
EphemeralInteractionReplyRenderer,
InteractionReplyRenderer,
} from "./renderer"
/**
* @category Core
*/
export type ReacordConfig = {
/** Discord bot token */
token: string
/**
* The max number of active instances.
* When this limit is exceeded, the oldest instances will be cleaned up
* to prevent memory leaks.
*/
maxInstances?: number
}
/**
* Info for replying to an interaction. For Discord.js
* (and probably other libraries) you should be able to pass the
* interaction object directly:
* ```js
* client.on("interactionCreate", (interaction) => {
* if (interaction.isChatInputCommand() && interaction.commandName === "hi") {
* reacord.reply(interacition, "hi lol")
* }
* })
* ```
* @category Core
*/
export type InteractionInfo = {
id: string
token: string
}
/**
* @category Core
*/
export class ReacordClient {
private readonly config: Required<ReacordConfig>
private readonly discordClientPromise: Promise<Client<true>>
private instances: ReacordInstancePrivate[] = []
destroyed = false
constructor(config: ReacordConfig) {
this.config = {
...config,
maxInstances: config.maxInstances ?? 50,
}
this.discordClientPromise = createDiscordClient(this.config.token, {
intents: [GatewayIntentBits.Guilds],
})
this.discordClientPromise
.then((client) => {
// we listen to the websocket message instead of the normal "interactionCreate" event,
// so that we can pass a library-agnostic APIInteraction object to the user's component callbacks
// the DJS MessageComponentInteraction doesn't have the raw data on it (as of writing this)
client.ws.on(
GatewayDispatchEvents.InteractionCreate,
async (interaction: APIInteraction) => {
if (interaction.type !== InteractionType.MessageComponent) return
// handling a component interaction may not always result in a re-render,
// and in the case that it doesn't, discord will incorrectly show "interaction failed",
// so here, we'll just always defer an update just in case
//
// we _can_ be a little smarter and check to see if an update happened before deferring,
// but I can figure that out later
//
// or we can make the user defer themselves if they don't update,
// but that's bad UX probably
await client.rest.post(
Routes.interactionCallback(interaction.id, interaction.token),
{ body: { type: InteractionResponseType.DeferredMessageUpdate } },
)
for (const instance of this.instances) {
instance.handleInteraction(interaction, this)
}
},
)
return client
})
.catch(console.error)
}
send(channelId: string, initialContent?: React.ReactNode) {
return this.createInstance(
new ChannelMessageRenderer(channelId, this.discordClientPromise),
initialContent,
)
}
reply(interaction: InteractionInfo, initialContent?: React.ReactNode) {
return this.createInstance(
new InteractionReplyRenderer(interaction, this.discordClientPromise),
initialContent,
)
}
ephemeralReply(
interaction: InteractionInfo,
initialContent?: React.ReactNode,
) {
return this.createInstance(
new EphemeralInteractionReplyRenderer(
interaction,
this.discordClientPromise,
),
initialContent,
)
}
destroy() {
void this.discordClientPromise.then((client) => client.destroy())
this.destroyed = true
}
private createInstance(renderer: Renderer, initialContent?: React.ReactNode) {
if (this.destroyed) throw new Error("ReacordClient is destroyed")
const instance = new ReacordInstancePrivate(renderer)
this.instances.push(instance)
if (this.instances.length > this.config.maxInstances) {
void this.instances[0]?.deactivate()
this.removeInstance(this.instances[0]!)
}
const publicInstance: ReacordInstance = {
render: (content: React.ReactNode) => {
instance.render(
React.createElement(
InstanceProvider,
{ value: publicInstance },
content,
),
)
},
deactivate: () => {
this.removeInstance(instance)
renderer.deactivate()
},
destroy: () => {
this.removeInstance(instance)
renderer.destroy()
},
}
if (initialContent !== undefined) {
publicInstance.render(initialContent)
}
return publicInstance
}
private removeInstance(instance: ReacordInstancePrivate) {
this.instances = this.instances.filter((the) => the !== instance)
}
}

View File

@@ -0,0 +1,122 @@
import type {
APIMessageComponentButtonInteraction,
APIMessageComponentInteraction,
APIMessageComponentSelectMenuInteraction,
} from "discord.js"
import { ComponentType } from "discord.js"
import type * as React from "react"
import { Node } from "./node"
import type { ReacordClient } from "./reacord-client"
import { ButtonNode } from "./react/button"
import type { ComponentEvent } from "./react/component-event"
import { reconciler } from "./react/reconciler"
import type { SelectChangeEvent } from "./react/select"
import { SelectNode } from "./react/select"
import type { Renderer } from "./renderer"
/**
* Represents an interactive message, which can later be replaced or deleted.
* @category Core
*/
export type ReacordInstance = {
/** Render some JSX to this instance (edits the message) */
render(content: React.ReactNode): void
/** Remove this message */
destroy(): void
/**
* Same as destroy, but keeps the message and disables the components on it.
* This prevents it from listening to user interactions.
*/
deactivate(): void
}
export class ReacordInstancePrivate {
private readonly container = reconciler.createContainer(
this,
0,
// eslint-disable-next-line unicorn/no-null
null,
false,
// eslint-disable-next-line unicorn/no-null
null,
"reacord",
() => {},
// eslint-disable-next-line unicorn/no-null
null,
)
readonly tree = new Node({})
private latestTree?: Node
constructor(readonly renderer: Renderer) {}
render(content: React.ReactNode) {
reconciler.updateContainer(content, this.container)
}
update(tree: Node) {
this.renderer.update(tree)
this.latestTree = tree
}
deactivate() {
this.renderer.deactivate()
}
destroy() {
this.renderer.destroy()
}
handleInteraction(
interaction: APIMessageComponentInteraction,
client: ReacordClient,
) {
if (!this.latestTree) return
this.renderer.onComponentInteraction(interaction)
const baseEvent: ComponentEvent = {
reply: (content) => client.reply(interaction, content),
ephemeralReply: (content) => client.ephemeralReply(interaction, content),
}
if (interaction.data.component_type === ComponentType.Button) {
for (const node of this.latestTree.walk()) {
if (
node instanceof ButtonNode &&
node.customId === interaction.data.custom_id
) {
node.props.onClick({
...baseEvent,
interaction: interaction as APIMessageComponentButtonInteraction,
})
return
}
}
}
if (interaction.data.component_type === ComponentType.SelectMenu) {
const event: SelectChangeEvent = {
...baseEvent,
interaction: interaction as APIMessageComponentSelectMenuInteraction,
values: interaction.data.values,
}
for (const node of this.latestTree.walk()) {
if (
node instanceof SelectNode &&
node.customId === interaction.data.custom_id
) {
node.props.onChange?.(event)
node.props.onChangeMultiple?.(interaction.data.values, event)
if (interaction.data.values[0]) {
node.props.onChangeValue?.(interaction.data.values[0], event)
}
return
}
}
}
}
}

View File

@@ -1,8 +1,7 @@
import type { ReactNode } from "react"
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import type { MessageOptions } from "../../internal/message"
import { Node } from "../../internal/node.js"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* Props for an action row
@@ -31,17 +30,10 @@ export type ActionRowProps = {
*/
export function ActionRow(props: ActionRowProps) {
return (
<ReacordElement props={props} createNode={() => new ActionRowNode(props)}>
<ReacordElement props={{}} createNode={() => new ActionRowNode({})}>
{props.children}
</ReacordElement>
)
}
class ActionRowNode extends Node<{}> {
override modifyMessageOptions(options: MessageOptions): void {
options.actionRows.push([])
for (const child of this.children) {
child.modifyMessageOptions(options)
}
}
}
export class ActionRowNode extends Node<{}> {}

View File

@@ -0,0 +1,49 @@
import type { APIMessageComponentButtonInteraction } from "discord.js"
import { randomUUID } from "node:crypto"
import React from "react"
import { Node } from "../node"
import type { ButtonSharedProps } from "./button-shared-props"
import type { ComponentEvent } from "./component-event"
import { ReacordElement } from "./reacord-element"
/**
* @category Button
*/
export type ButtonProps = ButtonSharedProps & {
/**
* The style determines the color of the button and signals intent.
* @see https://discord.com/developers/docs/interactions/message-components#button-object-button-styles
*/
style?: "primary" | "secondary" | "success" | "danger"
/**
* Happens when a user clicks the button.
*/
onClick: (event: ButtonClickEvent) => void
}
/**
* @category Button
*/
export type ButtonClickEvent = ComponentEvent & {
/**
* Event details, e.g. the user who clicked, guild member, guild id, etc.
* @see https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object
*/
interaction: APIMessageComponentButtonInteraction
}
/**
* @category Button
*/
export function Button(props: ButtonProps) {
return (
<ReacordElement props={props} createNode={() => new ButtonNode(props)}>
{props.label}
</ReacordElement>
)
}
export class ButtonNode extends Node<ButtonProps> {
readonly customId = randomUUID()
}

View File

@@ -0,0 +1,18 @@
import type { ReactNode } from "react"
import type { ReacordInstance } from "../reacord-instance"
/**
* @category Component Event
*/
export type ComponentEvent = {
/**
* Create a new reply to this event.
*/
reply(content?: ReactNode): ReacordInstance
/**
* Create an ephemeral reply to this event,
* shown only to the user who triggered it.
*/
ephemeralReply(content?: ReactNode): ReacordInstance
}

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from "react"
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Embed
*/
export type EmbedAuthorProps = {
name?: ReactNode
children?: ReactNode
url?: string
iconUrl?: string
}
/**
* @category Embed
*/
export function EmbedAuthor(props: EmbedAuthorProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedAuthorNode(props)}>
{props.name ?? props.children}
</ReacordElement>
)
}
export class EmbedAuthorNode extends Node<EmbedAuthorProps> {}

View File

@@ -0,0 +1,34 @@
import type { ReactNode } from "react"
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Embed
*/
export type EmbedFieldProps = {
name: ReactNode
value?: ReactNode
inline?: boolean
children?: ReactNode
}
/**
* @category Embed
*/
export function EmbedField(props: EmbedFieldProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedFieldNode(props)}>
<ReacordElement props={{}} createNode={() => new EmbedFieldNameNode({})}>
{props.name}
</ReacordElement>
<ReacordElement props={{}} createNode={() => new EmbedFieldValueNode({})}>
{props.value ?? props.children}
</ReacordElement>
</ReacordElement>
)
}
export class EmbedFieldNode extends Node<EmbedFieldProps> {}
export class EmbedFieldNameNode extends Node<{}> {}
export class EmbedFieldValueNode extends Node<{}> {}

View File

@@ -0,0 +1,29 @@
import type { ReactNode } from "react"
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Embed
*/
export type EmbedFooterProps = {
text?: ReactNode
children?: ReactNode
iconUrl?: string
timestamp?: string | number | Date
}
/**
* @category Embed
*/
export function EmbedFooter({ text, children, ...props }: EmbedFooterProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedFooterNode(props)}>
{text ?? children}
</ReacordElement>
)
}
export class EmbedFooterNode extends Node<
Omit<EmbedFooterProps, "text" | "children">
> {}

View File

@@ -0,0 +1,24 @@
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Embed
*/
export type EmbedImageProps = {
url: string
}
/**
* @category Embed
*/
export function EmbedImage(props: EmbedImageProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedImageNode(props)}
/>
)
}
export class EmbedImageNode extends Node<EmbedImageProps> {}

View File

@@ -0,0 +1,24 @@
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Embed
*/
export type EmbedThumbnailProps = {
url: string
}
/**
* @category Embed
*/
export function EmbedThumbnail(props: EmbedThumbnailProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedThumbnailNode(props)}
/>
)
}
export class EmbedThumbnailNode extends Node<EmbedThumbnailProps> {}

View File

@@ -0,0 +1,26 @@
import type { ReactNode } from "react"
import React from "react"
import type { Except } from "type-fest"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Embed
*/
export type EmbedTitleProps = {
children: ReactNode
url?: string
}
/**
* @category Embed
*/
export function EmbedTitle({ children, ...props }: EmbedTitleProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedTitleNode(props)}>
{children}
</ReacordElement>
)
}
export class EmbedTitleNode extends Node<Except<EmbedTitleProps, "children">> {}

View File

@@ -0,0 +1,36 @@
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Embed
* @see https://discord.com/developers/docs/resources/channel#embed-object
*/
export type EmbedProps = {
title?: string
description?: string
url?: string
color?: number
fields?: Array<{ name: string; value: string; inline?: boolean }>
author?: { name: string; url?: string; iconUrl?: string }
thumbnail?: { url: string }
image?: { url: string }
video?: { url: string }
footer?: { text: string; iconUrl?: string }
timestamp?: string | number | Date
children?: React.ReactNode
}
/**
* @category Embed
* @see https://discord.com/developers/docs/resources/channel#embed-object
*/
export function Embed(props: EmbedProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedNode(props)}>
{props.children}
</ReacordElement>
)
}
export class EmbedNode extends Node<EmbedProps> {}

View File

@@ -1,6 +1,6 @@
import { raise } from "@reacord/helpers/raise"
import * as React from "react"
import { raise } from "../../helpers/raise"
import type { ReacordInstance } from "./instance"
import type { ReacordInstance } from "../reacord-instance"
const Context = React.createContext<ReacordInstance | undefined>(undefined)

View File

@@ -0,0 +1,28 @@
import React from "react"
import type { Except } from "type-fest"
import { Node } from "../node"
import type { ButtonSharedProps } from "./button-shared-props"
import { ReacordElement } from "./reacord-element"
/**
* @category Link
*/
export type LinkProps = ButtonSharedProps & {
/** The URL the link should lead to */
url: string
/** The link text */
children?: string
}
/**
* @category Link
*/
export function Link({ label, children, ...props }: LinkProps) {
return (
<ReacordElement props={props} createNode={() => new LinkNode(props)}>
{label || children}
</ReacordElement>
)
}
export class LinkNode extends Node<Except<LinkProps, "label" | "children">> {}

View File

@@ -1,11 +1,7 @@
import type { ReactNode } from "react"
import React from "react"
import { ReacordElement } from "../../internal/element"
import {
OptionDescriptionNode,
OptionLabelNode,
OptionNode,
} from "./option-node"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Select
@@ -60,3 +56,9 @@ export function Option({
</ReacordElement>
)
}
export class OptionNode extends Node<
Omit<OptionProps, "children" | "label" | "description">
> {}
export class OptionLabelNode extends Node<{}> {}
export class OptionDescriptionNode extends Node<{}> {}

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react"
import React from "react"
import type { Node } from "./node"
import type { Node } from "../node"
export function ReacordElement<Props>(props: {
props: Props

View File

@@ -1,16 +1,15 @@
import type { HostConfig } from "react-reconciler"
/* eslint-disable unicorn/prefer-modern-dom-apis */
import { raise } from "@reacord/helpers/raise"
import ReactReconciler from "react-reconciler"
import { DefaultEventPriority } from "react-reconciler/constants"
import { raise } from "../../helpers/raise.js"
import { Node } from "./node.js"
import type { Renderer } from "./renderers/renderer"
import { TextNode } from "./text-node.js"
import { Node, TextNode } from "../node"
import type { ReacordInstancePrivate } from "../reacord-instance"
const config: HostConfig<
export const reconciler = ReactReconciler<
string, // Type,
Record<string, unknown>, // Props,
Renderer, // Container,
Node<unknown>, // Instance,
ReacordInstancePrivate, // Container,
Node, // Instance,
TextNode, // TextInstance,
never, // SuspenseInstance,
never, // HydratableInstance,
@@ -20,7 +19,7 @@ const config: HostConfig<
never, // ChildSet,
number, // TimeoutHandle,
number // NoTimeout,
> = {
>({
supportsMutation: true,
supportsPersistence: false,
supportsHydration: false,
@@ -49,7 +48,7 @@ const config: HostConfig<
return node
},
createTextInstance: (text) => new TextNode(text),
createTextInstance: (text) => new TextNode({ text }),
shouldSetTextContent: () => false,
detachDeletedInstance: (instance) => {},
beforeActiveInstanceBlur: () => {},
@@ -59,30 +58,30 @@ const config: HostConfig<
// eslint-disable-next-line unicorn/no-null
getInstanceFromScope: (scopeInstance: any) => null,
clearContainer: (renderer) => {
renderer.nodes.clear()
clearContainer: (instance) => {
instance.tree.clear()
},
appendChildToContainer: (renderer, child) => {
renderer.nodes.add(child)
appendChildToContainer: (instance, child) => {
instance.tree.add(child)
},
removeChildFromContainer: (renderer, child) => {
renderer.nodes.remove(child)
removeChildFromContainer: (instance, child) => {
instance.tree.remove(child)
},
insertInContainerBefore: (renderer, child, before) => {
renderer.nodes.addBefore(child, before)
insertInContainerBefore: (instance, child, before) => {
instance.tree.insertBefore(child, before)
},
appendInitialChild: (parent, child) => {
parent.children.add(child)
parent.add(child)
},
appendChild: (parent, child) => {
parent.children.add(child)
parent.add(child)
},
removeChild: (parent, child) => {
parent.children.remove(child)
parent.remove(child)
},
insertBefore: (parent, child, before) => {
parent.children.addBefore(child, before)
parent.insertBefore(child, before)
},
prepareUpdate: () => true,
@@ -90,13 +89,13 @@ const config: HostConfig<
node.props = newProps.props
},
commitTextUpdate: (node, oldText, newText) => {
node.props = newText
node.props.text = newText
},
// eslint-disable-next-line unicorn/no-null
prepareForCommit: () => null,
resetAfterCommit: (renderer) => {
renderer.render()
void renderer.update(renderer.tree)
},
prepareScopeUpdate: (scopeInstance: any, instance: any) => {},
@@ -106,6 +105,4 @@ const config: HostConfig<
finalizeInitialChildren: () => false,
getCurrentEventPriority: () => DefaultEventPriority,
}
export const reconciler = ReactReconciler(config)
})

View File

@@ -1,17 +1,10 @@
import type { APIMessageComponentSelectMenuInteraction } from "discord.js"
import { randomUUID } from "node:crypto"
import type { ReactNode } from "react"
import React from "react"
import { isInstanceOf } from "../../../helpers/is-instance-of"
import { ReacordElement } from "../../internal/element.js"
import type { ComponentInteraction } from "../../internal/interaction"
import type {
ActionRow,
ActionRowItem,
MessageOptions,
} from "../../internal/message"
import { Node } from "../../internal/node.js"
import type { ComponentEvent } from "../component-event"
import { OptionNode } from "./option-node"
import { Node } from "../node"
import type { ComponentEvent } from "./component-event"
import { ReacordElement } from "./reacord-element"
/**
* @category Select
@@ -73,7 +66,16 @@ export type SelectProps = {
* @category Select
*/
export type SelectChangeEvent = ComponentEvent & {
/** The set of values that were selected by the user.
* If `multiple`, this can have more than one value.
*/
values: string[]
/**
* Event details, e.g. the user who clicked, guild member, guild id, etc.
* @see https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object
*/
interaction: APIMessageComponentSelectMenuInteraction
}
/**
@@ -88,66 +90,6 @@ export function Select(props: SelectProps) {
)
}
class SelectNode extends Node<SelectProps> {
export class SelectNode extends Node<SelectProps> {
readonly customId = randomUUID()
override modifyMessageOptions(message: MessageOptions): void {
const actionRow: ActionRow = []
message.actionRows.push(actionRow)
const options = [...this.children]
.filter(isInstanceOf(OptionNode))
.map((node) => node.options)
const {
multiple,
value,
values,
minValues = 0,
maxValues = 25,
children,
onChange,
onChangeValue,
onChangeMultiple,
...props
} = this.props
const item: ActionRowItem = {
...props,
type: "select",
customId: this.customId,
options,
values: [],
}
if (multiple) {
item.minValues = minValues
item.maxValues = maxValues
if (values) item.values = values
}
if (!multiple && value != undefined) {
item.values = [value]
}
actionRow.push(item)
}
override handleComponentInteraction(
interaction: ComponentInteraction,
): boolean {
const isSelectInteraction =
interaction.type === "select" &&
interaction.customId === this.customId &&
!this.props.disabled
if (!isSelectInteraction) return false
this.props.onChange?.(interaction.event)
this.props.onChangeMultiple?.(interaction.event.values, interaction.event)
if (interaction.event.values[0]) {
this.props.onChangeValue?.(interaction.event.values[0], interaction.event)
}
return true
}
}

View File

@@ -0,0 +1,251 @@
import { AsyncQueue } from "@reacord/helpers/async-queue"
import type {
Client,
Message,
RESTPostAPIInteractionFollowupResult,
Snowflake,
} from "discord.js"
import { InteractionResponseType, Routes, TextChannel } from "discord.js"
import type { MessageUpdatePayload } from "./make-message-update-payload"
import { makeMessageUpdatePayload } from "./make-message-update-payload"
import type { Node } from "./node"
import type { InteractionInfo } from "./reacord-client"
export abstract class Renderer {
private active = true
private componentInteraction?: InteractionInfo
private readonly queue = new AsyncQueue()
constructor(protected readonly clientPromise: Promise<Client<true>>) {}
protected abstract handleUpdate(payload: MessageUpdatePayload): Promise<void>
protected abstract handleDestroy(): Promise<void>
protected abstract handleDeactivate(): Promise<void>
update(tree: Node) {
const payload = makeMessageUpdatePayload(tree)
this.queue
.append(async () => {
if (!this.active) return
if (this.componentInteraction) {
await this.updateInteractionMessage(
this.componentInteraction,
payload,
)
this.componentInteraction = undefined
return
}
await this.handleUpdate(payload)
})
.catch(console.error)
}
destroy() {
if (!this.active) return
this.active = false
this.queue.append(() => this.handleDestroy()).catch(console.error)
}
deactivate() {
this.queue
.append(async () => {
await this.handleDeactivate()
this.active = false
})
.catch(console.error)
}
onComponentInteraction(info: InteractionInfo) {
this.componentInteraction = info
// a component update might not happen in response to this interaction,
// so we'll defer it after a timeout if it's not handled by then
setTimeout(() => {
this.queue
.append(() => {
if (!this.componentInteraction) return
const info = this.componentInteraction
this.componentInteraction = undefined
return this.deferMessageUpdate(info)
})
.catch(console.error)
}, 500)
}
private async updateInteractionMessage(
{ id, token }: InteractionInfo,
payload: MessageUpdatePayload,
) {
const client = await this.clientPromise
await client.rest.post(Routes.interactionCallback(id, token), {
body: {
type: InteractionResponseType.UpdateMessage,
data: payload,
},
})
}
private async deferMessageUpdate({ id, token }: InteractionInfo) {
const client = await this.clientPromise
await client.rest.post(Routes.interactionCallback(id, token), {
body: { type: InteractionResponseType.DeferredMessageUpdate },
})
}
}
export class ChannelMessageRenderer extends Renderer {
private channel?: TextChannel
private message?: Message
constructor(
private readonly channelId: string,
clientPromise: Promise<Client<true>>,
) {
super(clientPromise)
}
override async handleUpdate(payload: MessageUpdatePayload): Promise<void> {
if (this.message) {
await this.message.edit(payload)
return
}
const channel = await this.getChannel()
this.message = await channel.send(payload)
}
override async handleDestroy(): Promise<void> {
const message = this.message
this.message = undefined
await message?.delete()
}
override async handleDeactivate(): Promise<void> {
throw new Error("not implemented")
}
private async getChannel(): Promise<TextChannel> {
if (this.channel) return this.channel
const client = await this.clientPromise
const channel =
client.channels.cache.get(this.channelId) ??
(await client.channels.fetch(this.channelId))
if (!(channel instanceof TextChannel)) {
throw new TypeError(`Channel ${this.channelId} is not a text channel`)
}
this.channel = channel
return channel
}
}
export class InteractionReplyRenderer extends Renderer {
private messageCreated = false
constructor(
private interaction: InteractionInfo,
clientPromise: Promise<Client<true>>,
) {
super(clientPromise)
}
async handleUpdate(payload: MessageUpdatePayload): Promise<void> {
const client = await this.clientPromise
if (!this.messageCreated) {
await client.rest.post(
Routes.interactionCallback(this.interaction.id, this.interaction.token),
{
body: {
type: InteractionResponseType.ChannelMessageWithSource,
data: payload,
},
},
)
this.messageCreated = true
} else {
await client.rest.patch(
Routes.webhookMessage(
client.application.id,
this.interaction.token,
"@original",
),
{ body: payload },
)
}
}
handleDestroy(): Promise<void> {
throw new Error("Method not implemented.")
}
handleDeactivate(): Promise<void> {
throw new Error("Method not implemented.")
}
}
export class InteractionFollowUpRenderer extends Renderer {
private messageId?: Snowflake
constructor(
readonly interaction: InteractionInfo,
clientPromise: Promise<Client<true>>,
) {
super(clientPromise)
}
async handleUpdate(payload: MessageUpdatePayload): Promise<void> {
const client = await this.clientPromise
if (!this.messageId) {
const response = (await client.rest.post(
Routes.webhookMessage(client.application.id, this.interaction.token),
{ body: payload },
)) as RESTPostAPIInteractionFollowupResult
this.messageId = response.id
} else {
await client.rest.patch(
Routes.webhookMessage(
client.application.id,
this.interaction.token,
this.messageId,
),
{ body: payload },
)
}
}
handleDestroy(): Promise<void> {
throw new Error("Method not implemented.")
}
handleDeactivate(): Promise<void> {
throw new Error("Method not implemented.")
}
}
export class EphemeralInteractionReplyRenderer extends Renderer {
constructor(
private readonly interaction: InteractionInfo,
clientPromise: Promise<Client<true>>,
) {
super(clientPromise)
}
handleUpdate(payload: MessageUpdatePayload): Promise<void> {
throw new Error("Method not implemented.")
}
handleDestroy(): Promise<void> {
throw new Error("Method not implemented.")
}
handleDeactivate(): Promise<void> {
throw new Error("Method not implemented.")
}
}

View File

@@ -1,41 +1,60 @@
import { ComponentType } from "discord.js"
import React from "react"
import { test } from "vitest"
import { ActionRow, Button, Select } from "../library/main"
import { ReacordTester } from "./test-adapter"
const testing = new ReacordTester()
import { expect, test } from "vitest"
import { ActionRow, Button, Option, Select } from "../src/main"
import { ReacordTester } from "./tester"
test("action row", async () => {
await testing.assertRender(
const { message } = await ReacordTester.render(
"action row",
<>
<Button label="outside button" onClick={() => {}} />
<ActionRow>
<Button label="button inside action row" onClick={() => {}} />
</ActionRow>
<Select />
<Select value="the">
<Option value="the" />
</Select>
<Button label="last row 1" onClick={() => {}} />
<Button label="last row 2" onClick={() => {}} />
</>,
[
{
content: "",
embeds: [],
actionRows: [
[{ type: "button", style: "secondary", label: "outside button" }],
[
{
type: "button",
style: "secondary",
label: "button inside action row",
},
],
[{ type: "select", options: [], values: [] }],
[
{ type: "button", style: "secondary", label: "last row 1" },
{ type: "button", style: "secondary", label: "last row 2" },
],
],
},
],
)
expect(message.components.map((c) => c.toJSON())).toEqual([
{
type: ComponentType.ActionRow,
components: [
expect.objectContaining({
type: ComponentType.Button,
label: "outside button",
}),
],
},
{
type: ComponentType.ActionRow,
components: [
expect.objectContaining({
type: ComponentType.Button,
label: "button inside action row",
}),
],
},
{
type: ComponentType.ActionRow,
components: [expect.objectContaining({ type: ComponentType.SelectMenu })],
},
{
type: ComponentType.ActionRow,
components: [
expect.objectContaining({
type: ComponentType.Button,
label: "last row 1",
}),
expect.objectContaining({
type: ComponentType.Button,
label: "last row 2",
}),
],
},
])
})

View File

@@ -1,9 +1,13 @@
import { spawnSync } from "node:child_process"
import { createRequire } from "node:module"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
import { beforeAll, expect, test } from "vitest"
beforeAll(() => {
spawnSync("pnpm", ["run", "build"])
spawnSync("pnpm", ["run", "build"], {
cwd: join(fileURLToPath(import.meta.url), ".."),
})
})
test("can require commonjs", () => {

View File

@@ -1,3 +0,0 @@
import { test } from "vitest"
test.todo("discord js integration")

View File

@@ -1,5 +1,5 @@
import React from "react"
import { test } from "vitest"
import { expect, test } from "vitest"
import {
Embed,
EmbedAuthor,
@@ -8,15 +8,14 @@ import {
EmbedImage,
EmbedThumbnail,
EmbedTitle,
} from "../library/main"
import { ReacordTester } from "./test-adapter"
const testing = new ReacordTester()
} from "../src/main"
import { ReacordTester } from "./tester"
test("kitchen sink", async () => {
const now = new Date()
await testing.assertRender(
const { message } = await ReacordTester.render(
"kitchen sink",
<>
<Embed color={0xfe_ee_ef}>
<EmbedAuthor name="author" iconUrl="https://example.com/author.png" />
@@ -33,85 +32,83 @@ test("kitchen sink", async () => {
/>
</Embed>
</>,
[
{
actionRows: [],
content: "",
embeds: [
{
description: "description text",
author: {
icon_url: "https://example.com/author.png",
name: "author",
},
color: 0xfe_ee_ef,
fields: [
{
inline: true,
name: "field name",
value: "field value",
},
{
name: "block field",
value: "block field value",
},
],
footer: {
icon_url: "https://example.com/footer.png",
text: "footer text",
},
image: {
url: "https://example.com/image.png",
},
thumbnail: {
url: "https://example.com/thumbnail.png",
},
timestamp: now.toISOString(),
title: "title text",
},
],
},
],
)
expect(message.embeds.map((e) => e.toJSON())).toEqual([
expect.objectContaining({
description: "description text",
author: expect.objectContaining({
icon_url: "https://example.com/author.png",
name: "author",
}),
color: 0xfe_ee_ef,
fields: [
{
inline: true,
name: "field name",
value: "field value",
},
{
inline: false,
name: "block field",
value: "block field value",
},
],
footer: expect.objectContaining({
icon_url: "https://example.com/footer.png",
text: "footer text",
}),
image: expect.objectContaining({
url: "https://example.com/image.png",
}),
thumbnail: expect.objectContaining({
url: "https://example.com/thumbnail.png",
}),
title: "title text",
}),
])
// the timestamp format from Discord is not the same one that JS makes
expect(new Date(message.embeds[0]!.timestamp!)).toEqual(now)
})
test("author variants", async () => {
await testing.assertRender(
const { message } = await ReacordTester.render(
"author variants",
<>
<Embed>
<EmbedAuthor iconUrl="https://example.com/author.png">
author name
author name 1
</EmbedAuthor>
</Embed>
<Embed>
<EmbedAuthor iconUrl="https://example.com/author.png" />
<EmbedAuthor
name="author name 2"
iconUrl="https://example.com/author.png"
/>
</Embed>
</>,
[
{
content: "",
actionRows: [],
embeds: [
{
author: {
icon_url: "https://example.com/author.png",
name: "author name",
},
},
{
author: {
icon_url: "https://example.com/author.png",
name: "",
},
},
],
},
],
)
})
expect(message.embeds.map((e) => e.toJSON())).toEqual([
expect.objectContaining({
author: expect.objectContaining({
name: "author name 1",
icon_url: "https://example.com/author.png",
}),
}),
expect.objectContaining({
author: expect.objectContaining({
name: "author name 2",
icon_url: "https://example.com/author.png",
}),
}),
])
}, 20_000)
test("field variants", async () => {
await testing.assertRender(
const { message } = await ReacordTester.render(
"field variants",
<>
<Embed>
<EmbedField name="field name" value="field value" />
@@ -122,43 +119,41 @@ test("field variants", async () => {
<EmbedField name="field name" />
</Embed>
</>,
[
{
content: "",
actionRows: [],
embeds: [
{
fields: [
{
name: "field name",
value: "field value",
},
{
inline: true,
name: "field name",
value: "field value",
},
{
inline: true,
name: "field name",
value: "field value",
},
{
name: "field name",
value: "",
},
],
},
],
},
],
)
expect(message.embeds.map((e) => e.toJSON())).toEqual([
expect.objectContaining({
fields: [
{
name: "field name",
value: "field value",
inline: false,
},
{
name: "field name",
value: "field value",
inline: true,
},
{
name: "field name",
value: "field value",
inline: true,
},
{
name: "field name",
value: "_ _",
inline: false,
},
],
}),
])
})
test("footer variants", async () => {
const now = new Date()
await testing.assertRender(
const { message } = await ReacordTester.render(
"footer variants",
<>
<Embed>
<EmbedFooter text="footer text" />
@@ -176,45 +171,37 @@ test("footer variants", async () => {
<EmbedFooter iconUrl="https://example.com/footer.png" timestamp={now} />
</Embed>
</>,
[
{
content: "",
actionRows: [],
embeds: [
{
footer: {
text: "footer text",
},
},
{
footer: {
icon_url: "https://example.com/footer.png",
text: "footer text",
},
},
{
footer: {
text: "footer text",
},
timestamp: now.toISOString(),
},
{
footer: {
icon_url: "https://example.com/footer.png",
text: "",
},
timestamp: now.toISOString(),
},
],
},
],
)
expect(message.embeds.map((e) => e.toJSON())).toEqual([
expect.objectContaining({
footer: {
text: "footer text",
},
}),
expect.objectContaining({
footer: expect.objectContaining({
icon_url: "https://example.com/footer.png",
text: "footer text",
}),
}),
expect.objectContaining({
timestamp: expect.stringContaining(""),
}),
expect.objectContaining({
timestamp: expect.stringContaining(""),
}),
])
expect(new Date(message.embeds[2]!.timestamp!)).toEqual(now)
expect(new Date(message.embeds[3]!.timestamp!)).toEqual(now)
})
test("embed props", async () => {
const now = new Date()
await testing.assertRender(
const { message } = await ReacordTester.render(
"embed props",
<Embed
title="title text"
description="description text"
@@ -241,35 +228,33 @@ test("embed props", async () => {
{ name: "block field", value: "block field value" },
]}
/>,
[
{
content: "",
actionRows: [],
embeds: [
{
title: "title text",
description: "description text",
url: "https://example.com/",
color: 0xfe_ee_ef,
timestamp: now.toISOString(),
author: {
name: "author name",
url: "https://example.com/author",
icon_url: "https://example.com/author.png",
},
thumbnail: { url: "https://example.com/thumbnail.png" },
image: { url: "https://example.com/image.png" },
footer: {
text: "footer text",
icon_url: "https://example.com/footer.png",
},
fields: [
{ name: "field name", value: "field value", inline: true },
{ name: "block field", value: "block field value" },
],
},
],
},
],
)
expect(message.embeds.map((e) => e.toJSON())).toEqual([
expect.objectContaining({
title: "title text",
description: "description text",
url: "https://example.com/",
color: 0xfe_ee_ef,
author: expect.objectContaining({
name: "author name",
url: "https://example.com/author",
icon_url: "https://example.com/author.png",
}),
thumbnail: expect.objectContaining({
url: "https://example.com/thumbnail.png",
}),
image: expect.objectContaining({ url: "https://example.com/image.png" }),
footer: expect.objectContaining({
text: "footer text",
icon_url: "https://example.com/footer.png",
}),
fields: [
{ name: "field name", value: "field value", inline: true },
{ name: "block field", value: "block field value", inline: false },
],
}),
])
expect(new Date(message.embeds[0]!.timestamp!)).toEqual(now)
})

View File

@@ -0,0 +1,6 @@
import { oraPromise } from "ora"
import { ReacordTester } from "./tester"
export async function setup() {
await oraPromise(ReacordTester.removeChannels(), "Running test setup...")
}

View File

@@ -1,42 +1,43 @@
import { ButtonStyle, ComponentType } from "discord.js"
import React from "react"
import { test } from "vitest"
import { Link } from "../library/main"
import { ReacordTester } from "./test-adapter"
const tester = new ReacordTester()
import { expect, test } from "vitest"
import { Link } from "../src/main"
import { ReacordTester } from "./tester"
test("link", async () => {
await tester.assertRender(
const { message } = await ReacordTester.render(
"link",
<>
<Link url="https://example.com/">link text</Link>
<Link label="link text" url="https://example.com/" />
<Link label="link text" url="https://example.com/" disabled />
</>,
[
{
content: "",
embeds: [],
actionRows: [
[
{
type: "link",
url: "https://example.com/",
label: "link text",
},
{
type: "link",
url: "https://example.com/",
label: "link text",
},
{
type: "link",
url: "https://example.com/",
label: "link text",
disabled: true,
},
],
],
},
],
)
expect(message.components.map((c) => c.toJSON())).toEqual([
{
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Link,
url: "https://example.com/",
label: "link text",
},
{
type: ComponentType.Button,
style: ButtonStyle.Link,
url: "https://example.com/",
label: "link text",
},
{
type: ComponentType.Button,
style: ButtonStyle.Link,
url: "https://example.com/",
label: "link text",
disabled: true,
},
],
},
])
})

View File

@@ -1,251 +1,241 @@
import { waitFor } from "@reacord/helpers/wait-for.js"
import * as React from "react"
import { test } from "vitest"
import { Button, Embed, EmbedField, EmbedTitle } from "../library/main"
import { ReacordTester } from "./test-adapter"
import { expect, test } from "vitest"
import { Button, Embed, EmbedField, EmbedTitle } from "../src/main"
import { ReacordTester } from "./tester"
test("rendering behavior", async () => {
const tester = new ReacordTester()
const reply = tester.reply()
reply.render(<KitchenSinkCounter onDeactivate={() => reply.deactivate()} />)
await tester.assertMessages([
{
content: "count: 0",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "secondary",
label: "show embed",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("show embed").click()
await tester.assertMessages([
{
content: "count: 0",
embeds: [{ title: "the counter" }],
actionRows: [
[
{
type: "button",
style: "secondary",
label: "hide embed",
},
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 1",
embeds: [
{
title: "the counter",
fields: [{ name: "is it even?", value: "no" }],
},
],
actionRows: [
[
{
type: "button",
style: "secondary",
label: "hide embed",
},
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 2",
embeds: [
{
title: "the counter",
fields: [{ name: "is it even?", value: "yes" }],
},
],
actionRows: [
[
{
type: "button",
style: "secondary",
label: "hide embed",
},
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("hide embed").click()
await tester.assertMessages([
{
content: "count: 2",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "secondary",
label: "show embed",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 3",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "secondary",
label: "show embed",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("deactivate").click()
await tester.assertMessages([
{
content: "count: 3",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
disabled: true,
},
{
type: "button",
style: "secondary",
label: "show embed",
disabled: true,
},
{
type: "button",
style: "danger",
label: "deactivate",
disabled: true,
},
],
],
},
])
await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 3",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
disabled: true,
},
{
type: "button",
style: "secondary",
label: "show embed",
disabled: true,
},
{
type: "button",
style: "danger",
label: "deactivate",
disabled: true,
},
],
],
},
])
test.skip("rendering behavior", async () => {
// const tester = new ReacordTester()
// const reply = tester.reply()
// reply.render(<KitchenSinkCounter onDeactivate={() => reply.deactivate()} />)
// await tester.assertMessages([
// {
// content: "count: 0",
// embeds: [],
// actionRows: [
// [
// {
// type: "button",
// style: "primary",
// label: "clicc",
// },
// {
// type: "button",
// style: "secondary",
// label: "show embed",
// },
// {
// type: "button",
// style: "danger",
// label: "deactivate",
// },
// ],
// ],
// },
// ])
// await tester.findButtonByLabel("show embed").click()
// await tester.assertMessages([
// {
// content: "count: 0",
// embeds: [{ title: "the counter" }],
// actionRows: [
// [
// {
// type: "button",
// style: "secondary",
// label: "hide embed",
// },
// {
// type: "button",
// style: "primary",
// label: "clicc",
// },
// {
// type: "button",
// style: "danger",
// label: "deactivate",
// },
// ],
// ],
// },
// ])
// await tester.findButtonByLabel("clicc").click()
// await tester.assertMessages([
// {
// content: "count: 1",
// embeds: [
// {
// title: "the counter",
// fields: [{ name: "is it even?", value: "no" }],
// },
// ],
// actionRows: [
// [
// {
// type: "button",
// style: "secondary",
// label: "hide embed",
// },
// {
// type: "button",
// style: "primary",
// label: "clicc",
// },
// {
// type: "button",
// style: "danger",
// label: "deactivate",
// },
// ],
// ],
// },
// ])
// await tester.findButtonByLabel("clicc").click()
// await tester.assertMessages([
// {
// content: "count: 2",
// embeds: [
// {
// title: "the counter",
// fields: [{ name: "is it even?", value: "yes" }],
// },
// ],
// actionRows: [
// [
// {
// type: "button",
// style: "secondary",
// label: "hide embed",
// },
// {
// type: "button",
// style: "primary",
// label: "clicc",
// },
// {
// type: "button",
// style: "danger",
// label: "deactivate",
// },
// ],
// ],
// },
// ])
// await tester.findButtonByLabel("hide embed").click()
// await tester.assertMessages([
// {
// content: "count: 2",
// embeds: [],
// actionRows: [
// [
// {
// type: "button",
// style: "primary",
// label: "clicc",
// },
// {
// type: "button",
// style: "secondary",
// label: "show embed",
// },
// {
// type: "button",
// style: "danger",
// label: "deactivate",
// },
// ],
// ],
// },
// ])
// await tester.findButtonByLabel("clicc").click()
// await tester.assertMessages([
// {
// content: "count: 3",
// embeds: [],
// actionRows: [
// [
// {
// type: "button",
// style: "primary",
// label: "clicc",
// },
// {
// type: "button",
// style: "secondary",
// label: "show embed",
// },
// {
// type: "button",
// style: "danger",
// label: "deactivate",
// },
// ],
// ],
// },
// ])
// await tester.findButtonByLabel("deactivate").click()
// await tester.assertMessages([
// {
// content: "count: 3",
// embeds: [],
// actionRows: [
// [
// {
// type: "button",
// style: "primary",
// label: "clicc",
// disabled: true,
// },
// {
// type: "button",
// style: "secondary",
// label: "show embed",
// disabled: true,
// },
// {
// type: "button",
// style: "danger",
// label: "deactivate",
// disabled: true,
// },
// ],
// ],
// },
// ])
// await tester.findButtonByLabel("clicc").click()
// await tester.assertMessages([
// {
// content: "count: 3",
// embeds: [],
// actionRows: [
// [
// {
// type: "button",
// style: "primary",
// label: "clicc",
// disabled: true,
// },
// {
// type: "button",
// style: "secondary",
// label: "show embed",
// disabled: true,
// },
// {
// type: "button",
// style: "danger",
// label: "deactivate",
// disabled: true,
// },
// ],
// ],
// },
// ])
})
test("delete", async () => {
const tester = new ReacordTester()
const reply = tester.reply()
reply.render(
test("destroy()", async () => {
const { message, channel, instance } = await ReacordTester.render(
"destroy()",
<>
some text
<Embed>some embed</Embed>
@@ -253,18 +243,22 @@ test("delete", async () => {
</>,
)
await tester.assertMessages([
{
content: "some text",
embeds: [{ description: "some embed" }],
actionRows: [
[{ type: "button", style: "secondary", label: "some button" }],
],
},
expect(message.content).toBe("some text")
expect(message.embeds.map((e) => e.toJSON())).toEqual([
expect.objectContaining({ description: "some embed" }),
])
expect(message.components.map((a) => a.toJSON())).toEqual([
expect.objectContaining({
components: [expect.objectContaining({ label: "some button" })],
}),
])
reply.destroy()
await tester.assertMessages([])
instance.destroy()
await waitFor(async () => {
const messages = await channel.messages.fetch()
expect(messages.size).toBe(0)
})
})
// test multiple instances that can be updated independently,

View File

@@ -1,160 +1,143 @@
import React, { useState } from "react"
import { expect, test, vi } from "vitest"
import { Button, Option, Select } from "../library/main"
import { ReacordTester } from "./test-adapter"
import { test } from "vitest"
test("single select", async () => {
const tester = new ReacordTester()
const onSelect = vi.fn()
function TestSelect() {
const [value, setValue] = useState<string>()
const [disabled, setDisabled] = useState(false)
return (
<>
<Select
placeholder="choose one"
value={value}
onChange={onSelect}
onChangeValue={setValue}
disabled={disabled}
>
<Option value="1" />
<Option value="2" label="two" />
<Option value="3">three</Option>
</Select>
<Button label="disable" onClick={() => setDisabled(true)} />
</>
)
}
async function assertSelect(values: string[], disabled = false) {
await tester.assertMessages([
{
content: "",
embeds: [],
actionRows: [
[
{
type: "select",
placeholder: "choose one",
values,
disabled,
options: [
{ label: "1", value: "1" },
{ label: "two", value: "2" },
{ label: "three", value: "3" },
],
},
],
[{ type: "button", style: "secondary", label: "disable" }],
],
},
])
}
const reply = tester.reply()
reply.render(<TestSelect />)
await assertSelect([])
expect(onSelect).toHaveBeenCalledTimes(0)
await tester.findSelectByPlaceholder("choose one").select("2")
await assertSelect(["2"])
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ values: ["2"] }),
)
await tester.findButtonByLabel("disable").click()
await assertSelect(["2"], true)
await tester.findSelectByPlaceholder("choose one").select("1")
await assertSelect(["2"], true)
expect(onSelect).toHaveBeenCalledTimes(1)
test.skip("single select", async () => {
// const tester = new ReacordTester()
// const onSelect = vi.fn()
// function TestSelect() {
// const [value, setValue] = useState<string>()
// const [disabled, setDisabled] = useState(false)
// return (
// <>
// <Select
// placeholder="choose one"
// value={value}
// onChange={onSelect}
// onChangeValue={setValue}
// disabled={disabled}
// >
// <Option value="1" />
// <Option value="2" label="two" />
// <Option value="3">three</Option>
// </Select>
// <Button label="disable" onClick={() => setDisabled(true)} />
// </>
// )
// }
// async function assertSelect(values: string[], disabled = false) {
// await tester.assertMessages([
// {
// content: "",
// embeds: [],
// actionRows: [
// [
// {
// type: "select",
// placeholder: "choose one",
// values,
// disabled,
// options: [
// { label: "1", value: "1" },
// { label: "two", value: "2" },
// { label: "three", value: "3" },
// ],
// },
// ],
// [{ type: "button", style: "secondary", label: "disable" }],
// ],
// },
// ])
// }
// const reply = tester.reply()
// reply.render(<TestSelect />)
// await assertSelect([])
// expect(onSelect).toHaveBeenCalledTimes(0)
// await tester.findSelectByPlaceholder("choose one").select("2")
// await assertSelect(["2"])
// expect(onSelect).toHaveBeenCalledWith(
// expect.objectContaining({ values: ["2"] }),
// )
// await tester.findButtonByLabel("disable").click()
// await assertSelect(["2"], true)
// await tester.findSelectByPlaceholder("choose one").select("1")
// await assertSelect(["2"], true)
// expect(onSelect).toHaveBeenCalledTimes(1)
})
test("multiple select", async () => {
const tester = new ReacordTester()
const onSelect = vi.fn()
function TestSelect() {
const [values, setValues] = useState<string[]>([])
return (
<Select
placeholder="select"
multiple
values={values}
onChange={onSelect}
onChangeMultiple={setValues}
>
<Option value="1">one</Option>
<Option value="2">two</Option>
<Option value="3">three</Option>
</Select>
)
}
async function assertSelect(values: string[]) {
await tester.assertMessages([
{
content: "",
embeds: [],
actionRows: [
[
{
type: "select",
placeholder: "select",
values,
minValues: 0,
maxValues: 25,
options: [
{ label: "one", value: "1" },
{ label: "two", value: "2" },
{ label: "three", value: "3" },
],
},
],
],
},
])
}
const reply = tester.reply()
reply.render(<TestSelect />)
await assertSelect([])
expect(onSelect).toHaveBeenCalledTimes(0)
await tester.findSelectByPlaceholder("select").select("1", "3")
await assertSelect(expect.arrayContaining(["1", "3"]) as unknown as string[])
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ values: expect.arrayContaining(["1", "3"]) }),
)
await tester.findSelectByPlaceholder("select").select("2")
await assertSelect(expect.arrayContaining(["2"]) as unknown as string[])
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ values: expect.arrayContaining(["2"]) }),
)
await tester.findSelectByPlaceholder("select").select()
await assertSelect([])
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ values: [] }))
test.skip("multiple select", async () => {
// const tester = new ReacordTester()
// const onSelect = vi.fn()
// function TestSelect() {
// const [values, setValues] = useState<string[]>([])
// return (
// <Select
// placeholder="select"
// multiple
// values={values}
// onChange={onSelect}
// onChangeMultiple={setValues}
// >
// <Option value="1">one</Option>
// <Option value="2">two</Option>
// <Option value="3">three</Option>
// </Select>
// )
// }
// async function assertSelect(values: string[]) {
// await tester.assertMessages([
// {
// content: "",
// embeds: [],
// actionRows: [
// [
// {
// type: "select",
// placeholder: "select",
// values,
// minValues: 0,
// maxValues: 25,
// options: [
// { label: "one", value: "1" },
// { label: "two", value: "2" },
// { label: "three", value: "3" },
// ],
// },
// ],
// ],
// },
// ])
// }
// const reply = tester.reply()
// reply.render(<TestSelect />)
// await assertSelect([])
// expect(onSelect).toHaveBeenCalledTimes(0)
// await tester.findSelectByPlaceholder("select").select("1", "3")
// await assertSelect(expect.arrayContaining(["1", "3"]) as unknown as string[])
// expect(onSelect).toHaveBeenCalledWith(
// expect.objectContaining({ values: expect.arrayContaining(["1", "3"]) }),
// )
// await tester.findSelectByPlaceholder("select").select("2")
// await assertSelect(expect.arrayContaining(["2"]) as unknown as string[])
// expect(onSelect).toHaveBeenCalledWith(
// expect.objectContaining({ values: expect.arrayContaining(["2"]) }),
// )
// await tester.findSelectByPlaceholder("select").select()
// await assertSelect([])
// expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ values: [] }))
})
test("optional onSelect + unknown value", async () => {
const tester = new ReacordTester()
tester.reply().render(<Select placeholder="select" />)
await tester.findSelectByPlaceholder("select").select("something")
await tester.assertMessages([
{
content: "",
embeds: [],
actionRows: [
[{ type: "select", placeholder: "select", options: [], values: [] }],
],
},
])
test.skip("optional onSelect + unknown value", async () => {
// const tester = new ReacordTester()
// tester.reply().render(<Select placeholder="select" />)
// await tester.findSelectByPlaceholder("select").select("something")
// await tester.assertMessages([
// {
// content: "",
// embeds: [],
// actionRows: [
// [{ type: "select", placeholder: "select", options: [], values: [] }],
// ],
// },
// ])
})
test.todo("select minValues and maxValues")

View File

@@ -1,288 +0,0 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable require-await */
import { randomUUID } from "node:crypto"
import { setTimeout } from "node:timers/promises"
import type { ReactNode } from "react"
import { expect } from "vitest"
import { logPretty } from "../helpers/log-pretty"
import { omit } from "../helpers/omit"
import { pruneNullishValues } from "../helpers/prune-nullish-values"
import { raise } from "../helpers/raise"
import { waitFor } from "../helpers/wait-for"
import type {
ChannelInfo,
GuildInfo,
MessageInfo,
UserInfo,
} from "../library/core/component-event"
import type { ButtonClickEvent } from "../library/core/components/button"
import type { SelectChangeEvent } from "../library/core/components/select"
import type { ReacordInstance } from "../library/core/instance"
import { Reacord } from "../library/core/reacord"
import type { Channel } from "../library/internal/channel"
import { Container } from "../library/internal/container"
import type {
ButtonInteraction,
CommandInteraction,
SelectInteraction,
} from "../library/internal/interaction"
import type { Message, MessageOptions } from "../library/internal/message"
import { ChannelMessageRenderer } from "../library/internal/renderers/channel-message-renderer"
import { InteractionReplyRenderer } from "../library/internal/renderers/interaction-reply-renderer"
export type MessageSample = ReturnType<ReacordTester["sampleMessages"]>[0]
/**
* A Record adapter for automated tests. WIP
*/
export class ReacordTester extends Reacord {
private messageContainer = new Container<TestMessage>()
constructor() {
super({ maxInstances: 2 })
}
get messages(): readonly TestMessage[] {
return [...this.messageContainer]
}
override send(initialContent?: ReactNode): ReacordInstance {
return this.createInstance(
new ChannelMessageRenderer(new TestChannel(this.messageContainer)),
initialContent,
)
}
override reply(initialContent?: ReactNode): ReacordInstance {
return this.createInstance(
new InteractionReplyRenderer(
new TestCommandInteraction(this.messageContainer),
),
initialContent,
)
}
override ephemeralReply(initialContent?: ReactNode): ReacordInstance {
return this.reply(initialContent)
}
assertMessages(expected: MessageSample[]) {
return waitFor(() => {
expect(this.sampleMessages()).toEqual(expected)
})
}
async assertRender(content: ReactNode, expected: MessageSample[]) {
const instance = this.reply()
instance.render(content)
await this.assertMessages(expected)
instance.destroy()
}
logMessages() {
logPretty(this.sampleMessages())
}
sampleMessages() {
return pruneNullishValues(
this.messages.map((message) => ({
...message.options,
actionRows: message.options.actionRows.map((row) =>
row.map((component) =>
omit(component, [
"customId",
"onClick",
"onSelect",
"onSelectValue",
]),
),
),
})),
)
}
findButtonByLabel(label: string) {
return {
click: () => {
return waitFor(() => {
for (const [component, message] of this.eachComponent()) {
if (component.type === "button" && component.label === label) {
this.handleComponentInteraction(
new TestButtonInteraction(component.customId, message, this),
)
return
}
}
raise(`Couldn't find button with label "${label}"`)
})
},
}
}
findSelectByPlaceholder(placeholder: string) {
return {
select: (...values: string[]) => {
return waitFor(() => {
for (const [component, message] of this.eachComponent()) {
if (
component.type === "select" &&
component.placeholder === placeholder
) {
this.handleComponentInteraction(
new TestSelectInteraction(
component.customId,
message,
values,
this,
),
)
return
}
}
raise(`Couldn't find select with placeholder "${placeholder}"`)
})
},
}
}
createMessage(options: MessageOptions) {
return new TestMessage(options, this.messageContainer)
}
private *eachComponent() {
for (const message of this.messageContainer) {
for (const component of message.options.actionRows.flat()) {
yield [component, message] as const
}
}
}
}
class TestMessage implements Message {
constructor(
public options: MessageOptions,
private container: Container<TestMessage>,
) {
container.add(this)
}
async edit(options: MessageOptions): Promise<void> {
this.options = options
}
async delete(): Promise<void> {
this.container.remove(this)
}
}
class TestCommandInteraction implements CommandInteraction {
readonly type = "command"
readonly id = "test-command-interaction"
readonly channelId = "test-channel-id"
constructor(private messageContainer: Container<TestMessage>) {}
async reply(messageOptions: MessageOptions): Promise<Message> {
await setTimeout()
return new TestMessage(messageOptions, this.messageContainer)
}
async followUp(messageOptions: MessageOptions): Promise<Message> {
await setTimeout()
return new TestMessage(messageOptions, this.messageContainer)
}
}
class TestInteraction {
readonly id = randomUUID()
readonly channelId = "test-channel-id"
constructor(
readonly customId: string,
readonly message: TestMessage,
private tester: ReacordTester,
) {}
async update(options: MessageOptions): Promise<void> {
this.message.options = options
}
async deferUpdate(): Promise<void> {}
async reply(messageOptions: MessageOptions): Promise<Message> {
return this.tester.createMessage(messageOptions)
}
async followUp(messageOptions: MessageOptions): Promise<Message> {
return this.tester.createMessage(messageOptions)
}
}
class TestButtonInteraction
extends TestInteraction
implements ButtonInteraction
{
readonly type = "button"
readonly event: ButtonClickEvent
constructor(customId: string, message: TestMessage, tester: ReacordTester) {
super(customId, message, tester)
this.event = new TestButtonClickEvent(tester)
}
}
class TestSelectInteraction
extends TestInteraction
implements SelectInteraction
{
readonly type = "select"
readonly event: SelectChangeEvent
constructor(
customId: string,
message: TestMessage,
readonly values: string[],
tester: ReacordTester,
) {
super(customId, message, tester)
this.event = new TestSelectChangeEvent(values, tester)
}
}
class TestComponentEvent {
constructor(private tester: ReacordTester) {}
message: MessageInfo = {} as any // todo
channel: ChannelInfo = {} as any // todo
user: UserInfo = {} as any // todo
guild: GuildInfo = {} as any // todo
reply(content?: ReactNode): ReacordInstance {
return this.tester.reply(content)
}
ephemeralReply(content?: ReactNode): ReacordInstance {
return this.tester.ephemeralReply(content)
}
}
class TestButtonClickEvent
extends TestComponentEvent
implements ButtonClickEvent {}
class TestSelectChangeEvent
extends TestComponentEvent
implements SelectChangeEvent
{
constructor(readonly values: string[], tester: ReacordTester) {
super(tester)
}
}
class TestChannel implements Channel {
constructor(private messageContainer: Container<TestMessage>) {}
async send(messageOptions: MessageOptions): Promise<Message> {
return new TestMessage(messageOptions, this.messageContainer)
}
}

View File

@@ -0,0 +1,10 @@
import { raise } from "@reacord/helpers/raise"
import "dotenv/config"
const getEnv = (name: string) =>
process.env[name] ?? raise(`Missing env var: ${name}`)
export const testEnv = {
TEST_BOT_TOKEN: getEnv("TEST_BOT_TOKEN"),
TEST_CATEGORY_ID: getEnv("TEST_CATEGORY_ID"),
}

View File

@@ -0,0 +1,76 @@
import { raise } from "@reacord/helpers/raise"
import { CategoryChannel, ChannelType, GatewayIntentBits } from "discord.js"
import { kebabCase } from "lodash-es"
import { randomBytes } from "node:crypto"
import type { ReactNode } from "react"
import { createDiscordClient } from "../src/create-discord-client"
import { ReacordClient } from "../src/main"
import { testEnv } from "./test-env"
const reacord = new ReacordClient({
token: testEnv.TEST_BOT_TOKEN,
})
const clientPromise = createDiscordClient(testEnv.TEST_BOT_TOKEN, {
intents: GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages,
})
const categoryPromise = getCategory()
async function removeChannels() {
const category = await categoryPromise
for (const [, channel] of category.children.cache) {
await channel.delete()
}
}
async function getCategory() {
const client = await clientPromise
const category =
client.channels.cache.get(testEnv.TEST_CATEGORY_ID) ??
(await client.channels.fetch(testEnv.TEST_CATEGORY_ID))
if (!(category instanceof CategoryChannel)) {
throw new TypeError("Category channel not found")
}
return category
}
async function getTestChannel(testName: string) {
const hash = randomBytes(16).toString("hex").slice(0, 6)
const channelName = `${kebabCase(testName)}-${hash}`
const category = await categoryPromise
let channel = category.children.cache.find((the) => the.name === channelName)
if (!channel || !channel.isTextBased()) {
channel = await category.children.create({
type: ChannelType.GuildText,
name: channelName,
})
}
for (const [, message] of await channel.messages.fetch()) {
await message.delete()
}
return channel
}
async function render(testName: string, content?: ReactNode) {
const channel = await getTestChannel(testName)
await channel.sendTyping()
const instance = reacord.send(channel.id, content)
const result = await channel.awaitMessages({ max: 1 })
const message = result.first() ?? raise("failed to send message")
return { channel, message, instance }
}
export const ReacordTester = {
render,
removeChannels,
}

View File

@@ -1,89 +1,74 @@
import * as React from "react"
import { test } from "vitest"
import {
Button,
Embed,
EmbedAuthor,
EmbedField,
EmbedFooter,
EmbedTitle,
Link,
Option,
Select,
} from "../library/main"
import { ReacordTester } from "./test-adapter"
test("text children in other components", async () => {
const tester = new ReacordTester()
const SomeText = () => <>some text</>
await tester.assertRender(
<>
<Embed>
<EmbedTitle>
<SomeText />
</EmbedTitle>
<EmbedAuthor>
<SomeText />
</EmbedAuthor>
<EmbedField name={<SomeText />}>
<SomeText /> <Button label="ignore this" onClick={() => {}} />
nailed it
</EmbedField>
<EmbedFooter>
<SomeText />
</EmbedFooter>
</Embed>
<Button label={<SomeText />} onClick={() => {}} />
<Link url="https://discord.com" label={<SomeText />} />
<Select>
<Option value="1">
<SomeText />
</Option>
<Option value="2" label={<SomeText />} description={<SomeText />} />
</Select>
</>,
[
{
content: "",
embeds: [
{
title: "some text",
author: {
name: "some text",
},
fields: [{ name: "some text", value: "some text nailed it" }],
footer: {
text: "some text",
},
},
],
actionRows: [
[
{
type: "button",
label: "some text",
style: "secondary",
},
{
type: "link",
url: "https://discord.com",
label: "some text",
},
],
[
{
type: "select",
values: [],
options: [
{ value: "1", label: "some text" },
{ value: "2", label: "some text", description: "some text" },
],
},
],
],
},
],
)
test.skip("text children in other components", async () => {
// const tester = new ReacordTester()
// const SomeText = () => <>some text</>
// await tester.assertRender(
// <>
// <Embed>
// <EmbedTitle>
// <SomeText />
// </EmbedTitle>
// <EmbedAuthor>
// <SomeText />
// </EmbedAuthor>
// <EmbedField name={<SomeText />}>
// <SomeText /> <Button label="ignore this" onClick={() => {}} />
// nailed it
// </EmbedField>
// <EmbedFooter>
// <SomeText />
// </EmbedFooter>
// </Embed>
// <Button label={<SomeText />} onClick={() => {}} />
// <Link url="https://discord.com" label={<SomeText />} />
// <Select>
// <Option value="1">
// <SomeText />
// </Option>
// <Option value="2" label={<SomeText />} description={<SomeText />} />
// </Select>
// </>,
// [
// {
// content: "",
// embeds: [
// {
// title: "some text",
// author: {
// name: "some text",
// },
// fields: [{ name: "some text", value: "some text nailed it" }],
// footer: {
// text: "some text",
// },
// },
// ],
// actionRows: [
// [
// {
// type: "button",
// label: "some text",
// style: "secondary",
// },
// {
// type: "link",
// url: "https://discord.com",
// label: "some text",
// },
// ],
// [
// {
// type: "select",
// values: [],
// options: [
// { value: "1", label: "some text" },
// { value: "2", label: "some text", description: "some text" },
// ],
// },
// ],
// ],
// },
// ],
// )
})

View File

@@ -1,72 +1,60 @@
import React from "react"
import { describe, expect, it } from "vitest"
import type { ReacordInstance } from "../library/main"
import { Button, useInstance } from "../library/main"
import type { MessageSample } from "./test-adapter"
import { ReacordTester } from "./test-adapter"
import { describe, it } from "vitest"
describe("useInstance", () => {
it("returns the instance of itself", async () => {
let instanceFromHook: ReacordInstance | undefined
function TestComponent({ name }: { name: string }) {
const instance = useInstance()
instanceFromHook ??= instance
return (
<>
<Button
label={`create ${name}`}
onClick={(event) => {
event.reply(<TestComponent name="child" />)
}}
/>
<Button
label={`destroy ${name}`}
onClick={() => instance.destroy()}
/>
</>
)
}
function messageOutput(name: string): MessageSample {
return {
content: "",
embeds: [],
actionRows: [
[
{
type: "button",
label: `create ${name}`,
style: "secondary",
},
{
type: "button",
label: `destroy ${name}`,
style: "secondary",
},
],
],
}
}
const tester = new ReacordTester()
const instance = tester.send(<TestComponent name="parent" />)
await tester.assertMessages([messageOutput("parent")])
expect(instanceFromHook).toBe(instance)
await tester.findButtonByLabel("create parent").click()
await tester.assertMessages([
messageOutput("parent"),
messageOutput("child"),
])
// this test ensures that the only the child instance is destroyed,
// and not the parent instance
await tester.findButtonByLabel("destroy child").click()
await tester.assertMessages([messageOutput("parent")])
await tester.findButtonByLabel("destroy parent").click()
await tester.assertMessages([])
it.skip("returns the instance of itself", async () => {
// let instanceFromHook: ReacordInstance | undefined
// function TestComponent({ name }: { name: string }) {
// const instance = useInstance()
// instanceFromHook ??= instance
// return (
// <>
// <Button
// label={`create ${name}`}
// onClick={(event) => {
// event.reply(<TestComponent name="child" />)
// }}
// />
// <Button
// label={`destroy ${name}`}
// onClick={() => instance.destroy()}
// />
// </>
// )
// }
// function messageOutput(name: string): MessageSample {
// return {
// content: "",
// embeds: [],
// actionRows: [
// [
// {
// type: "button",
// label: `create ${name}`,
// style: "secondary",
// },
// {
// type: "button",
// label: `destroy ${name}`,
// style: "secondary",
// },
// ],
// ],
// }
// }
// const tester = new ReacordTester()
// const instance = tester.send(<TestComponent name="parent" />)
// await tester.assertMessages([messageOutput("parent")])
// expect(instanceFromHook).toBe(instance)
// await tester.findButtonByLabel("create parent").click()
// await tester.assertMessages([
// messageOutput("parent"),
// messageOutput("child"),
// ])
// // this test ensures that the only the child instance is destroyed,
// // and not the parent instance
// await tester.findButtonByLabel("destroy child").click()
// await tester.assertMessages([messageOutput("parent")])
// await tester.findButtonByLabel("destroy parent").click()
// await tester.assertMessages([])
})
})

View File

@@ -1,8 +0,0 @@
/// <reference types="vitest" />
import { defineConfig } from "vitest/config"
export default defineConfig({
build: {
sourcemap: true,
},
})

View File

@@ -1,6 +1,6 @@
import { renderToString } from "react-dom/server"
import type { EntryContext } from "@remix-run/node"
import { RemixServer } from "@remix-run/react"
import * as remixRunReact from "@remix-run/react"
import { renderToString } from "react-dom/server"
export default function handleRequest(
request: Request,
@@ -9,7 +9,9 @@ export default function handleRequest(
remixContext: EntryContext,
) {
const markup = renderToString(
<RemixServer context={remixContext} url={request.url} />,
// for some reason, a named import becomes `undefined` here,
// a namespace import makes it work...? 🙃
<remixRunReact.RemixServer context={remixContext} url={request.url} />,
)
responseHeaders.set("Content-Type", "text/html")

View File

@@ -14,9 +14,9 @@
"@headlessui/react": "^1.6.6",
"@heroicons/react": "^1.0.6",
"@reach/rect": "^0.17.0",
"@remix-run/node": "^1.6.5",
"@remix-run/react": "^1.6.5",
"@remix-run/vercel": "^1.7.2",
"@remix-run/node": "^1.6.7",
"@remix-run/react": "^1.6.7",
"@remix-run/serve": "^1.6.7",
"@tailwindcss/typography": "^0.5.4",
"@vercel/node": "^2.5.21",
"clsx": "^1.2.1",
@@ -31,21 +31,20 @@
"zod": "^3.17.10"
},
"devDependencies": {
"@remix-run/dev": "^1.6.5",
"@remix-run/serve": "^1.6.5",
"@remix-run/dev": "^1.6.7",
"@testing-library/cypress": "^8.0.3",
"@types/node": "*",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@types/wait-on": "^5.3.1",
"autoprefixer": "^10.4.7",
"autoprefixer": "^10.4.8",
"concurrently": "^7.3.0",
"cypress": "^10.3.1",
"cypress": "^10.4.0",
"execa": "^6.1.0",
"postcss": "^8.4.14",
"rehype-prism-plus": "^1.4.2",
"tailwindcss": "^3.1.6",
"typedoc": "^0.23.8",
"tailwindcss": "^3.1.8",
"typedoc": "^0.23.10",
"typescript": "^4.7.4",
"wait-on": "^6.0.1"
},

View File

@@ -1,4 +1,4 @@
// @ts-nocheck
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./app/**/*.{ts,tsx,md}"],
theme: {

Some files were not shown because too many files have changed in this diff Show More