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
196 changed files with 12949 additions and 8530 deletions

25
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,25 @@
require("@rushstack/eslint-patch/modern-module-resolution")
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [require.resolve("@itsmapleleaf/configs/eslint")],
ignorePatterns: [
"**/node_modules/**",
"**/.cache/**",
"**/build/**",
"**/dist/**",
"**/coverage/**",
"**/public/**",
],
parserOptions: {
project: require.resolve("./tsconfig.base.json"),
},
overrides: [
{
files: ["packages/website/cypress/**"],
parserOptions: {
project: require.resolve("./packages/website/cypress/tsconfig.json"),
},
},
],
}

50
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: main
on:
push:
branches: [main]
pull_request:
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 }}
cancel-in-progress: true
jobs:
run-commands:
strategy:
fail-fast: false
matrix:
command:
# if tests run in the same process, it dies,
# so we test them separate
- name: test
run: pnpm test
- name: test website
# 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
run: pnpm run lint
- name: typecheck
run: pnpm --recursive run typecheck
name: ${{ matrix.command.name }}
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"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: ${{ matrix.command.run }}

View File

@@ -13,15 +13,19 @@ jobs:
name: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
- name: checkout
uses: actions/checkout@v2
- name: setup node
uses: actions/setup-node@v2
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: 18
cache: pnpm
- run: pnpm install --frozen-lockfile
node-version: 16
- name: install pnpm
run: npm install -g pnpm
- name: install deps
run: pnpm install --frozen-lockfile
- name: changesets release
id: changesets

View File

@@ -1,44 +0,0 @@
name: tests
on:
push:
branches: [main]
pull_request:
env:
TEST_BOT_TOKEN: ${{ secrets.TEST_BOT_TOKEN }}
TEST_CHANNEL_ID: ${{ secrets.TEST_CHANNEL_ID }}
TEST_GUILD_ID: ${{ secrets.TEST_GUILD_ID }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tests:
name: ${{ matrix.script }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
script:
- lint
- build
- test
steps:
- uses: actions/checkout@v3
- uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/pnpm-lock.yaml') }}
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: 18
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run ${{ matrix.script }}
- uses: stefanzweifel/git-auto-commit-action@v4
if: always()

3
.gitignore vendored
View File

@@ -5,7 +5,6 @@ coverage
.env
*.code-workspace
.pnpm-debug.log
build
.cache
.vercel
*.tsbuildinfo

View File

@@ -1,3 +1,7 @@
node_modules
dist
coverage
pnpm-lock.yaml
/packages/website/public/api
.astro
build
.cache
packages/website/public/api

View File

@@ -2,46 +2,30 @@
"name": "reacord-monorepo",
"private": true,
"scripts": {
"lint": "run-s --continue-on-error lint:*",
"lint:eslint": "eslint . --fix --cache --cache-file=node_modules/.cache/.eslintcache --report-unused-disable-directives",
"lint:prettier": "prettier . --write --cache --list-different",
"lint:types": "tsc -b & pnpm -r --parallel run typecheck",
"astro-sync": "pnpm --filter website exec astro sync",
"format": "run-s --continue-on-error format:*",
"format:eslint": "eslint . --report-unused-disable-directives --fix",
"format:prettier": "prettier --cache --write . \"**/*.astro\"",
"test": "vitest",
"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",
"build:website": "pnpm --filter website... run build",
"start": "pnpm -C packages/website run start",
"start:website": "pnpm -C packages/website run start",
"release": "pnpm -r run build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.26.2",
"@itsmapleleaf/configs": "github:itsMapleLeaf/configs",
"eslint": "^8.50.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.0.3",
"react": "^18.2.0",
"tailwindcss": "^3.3.3",
"typescript": "^5.2.2",
"vitest": "^0.34.5"
"@changesets/cli": "^2.24.2",
"@itsmapleleaf/configs": "^1.1.5",
"@rushstack/eslint-patch": "^1.1.4",
"@types/eslint": "^8.4.5",
"@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",
"vitest": "^0.21.0"
},
"prettier": "@itsmapleleaf/configs/prettier",
"eslintConfig": {
"extends": [
"./node_modules/@itsmapleleaf/configs/eslint.config.cjs",
"./node_modules/@itsmapleleaf/configs/eslint.config.react.cjs"
],
"ignorePatterns": [
"node_modules",
"dist",
"packages/website/public/api"
],
"rules": {
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/require-await": "off"
}
}
"resolutions": {
"esbuild": "latest"
},
"prettier": "@itsmapleleaf/configs/prettier"
}

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

@@ -1,9 +1,9 @@
import { camelCaseDeep, snakeCaseDeep } from "./convert-object-property-case"
import type {
CamelCasedPropertiesDeep,
SnakeCasedPropertiesDeep,
} from "type-fest"
import { expect, test } from "vitest"
import { camelCaseDeep, snakeCaseDeep } from "./convert-object-property-case"
test("camelCaseDeep", () => {
const input = {

View File

@@ -2,7 +2,6 @@ import { camelCase, isObject, snakeCase } from "lodash-es"
import type {
CamelCasedPropertiesDeep,
SnakeCasedPropertiesDeep,
UnknownRecord,
} from "type-fest"
function convertKeyCaseDeep<Input, Output>(
@@ -19,11 +18,11 @@ function convertKeyCaseDeep<Input, Output>(
) as unknown as Output
}
const output = {} as UnknownRecord
const output: any = {}
for (const [key, value] of Object.entries(input)) {
output[convertKey(key)] = convertKeyCaseDeep(value, convertKey)
}
return output as Output
return output
}
export function camelCaseDeep<T>(input: T): CamelCasedPropertiesDeep<T> {

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

@@ -1,7 +1,7 @@
/** For narrowing instance types with array.filter */
/**
* for narrowing instance types with array.filter
*/
export const isInstanceOf =
<Instance, Args extends unknown[]>(
constructor: new (...args: Args) => Instance,
) =>
(value: unknown): value is Instance =>
value instanceof constructor
<T>(Constructor: new (...args: any[]) => T) =>
(value: unknown): value is T =>
value instanceof Constructor

View File

@@ -1,3 +1,7 @@
export function isObject(value: unknown): value is object {
export function isObject<T>(
value: T,
): value is Exclude<T, Primitive | AnyFunction> {
return typeof value === "object" && value !== null
}
type Primitive = string | number | boolean | undefined | null
type AnyFunction = (...args: any[]) => any

View File

@@ -1,7 +0,0 @@
export function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}

View File

@@ -1,7 +0,0 @@
import { expect, test } from "vitest"
import { omit } from "./omit.ts"
test("omit", () => {
const subject = { a: 1, b: true }
expect(omit(subject, ["a"])).toStrictEqual({ b: true })
})

View File

@@ -1,3 +0,0 @@
import { omit } from "./omit.ts"
omit({ a: 1, b: true }, ["a"]) satisfies { b: boolean }

View File

@@ -1,10 +1,13 @@
export function omit<Subject extends object, Key extends PropertyKey>(
subject: Subject,
keys: Key[],
) {
const keySet = new Set<PropertyKey>(keys)
return Object.fromEntries(
Object.entries(subject).filter(([key]) => !keySet.has(key)),
// hack: conditional type preserves unions
) as Subject extends unknown ? Omit<Subject, Key> : never
// hack: using a conditional type preserves union types
): Subject extends any ? Omit<Subject, Key> : never {
const result: any = {}
for (const key in subject) {
if (!keys.includes(key as unknown as Key)) {
result[key] = subject[key]
}
}
return result
}

View File

@@ -1,14 +1,11 @@
{
"name": "@reacord/helpers",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"typecheck": "tsc -b"
},
"dependencies": {
"@types/lodash-es": "^4.17.9",
"@types/lodash-es": "^4.17.6",
"@types/node": "*",
"lodash-es": "^4.17.21",
"type-fest": "^4.3.2",
"vitest": "^0.34.5"
"type-fest": "^2.18.0"
}
}

View File

@@ -1,11 +1,15 @@
import type { LoosePick } from "./types"
import type { LoosePick, UnknownRecord } from "./types"
export function pick<T extends object, K extends keyof T | PropertyKey>(
export function pick<T, K extends keyof T | PropertyKey>(
object: T,
keys: K[],
) {
const keySet = new Set<PropertyKey>(keys)
return Object.fromEntries(
Object.entries(object).filter(([key]) => keySet.has(key)),
) as LoosePick<T, K>
): LoosePick<T, K> {
const result: any = {}
for (const key of keys) {
const value = (object as UnknownRecord)[key]
if (value !== undefined) {
result[key] = value
}
}
return result
}

View File

@@ -3,7 +3,7 @@ import type { PruneNullishValues } from "./prune-nullish-values"
import { pruneNullishValues } from "./prune-nullish-values"
test("pruneNullishValues", () => {
interface InputType {
type InputType = {
a: string
b: string | null | undefined
c?: string
@@ -15,6 +15,7 @@ test("pruneNullishValues", () => {
const input: InputType = {
a: "a",
// eslint-disable-next-line unicorn/no-null
b: null,
c: undefined,
d: {

View File

@@ -1,25 +1,21 @@
import { isObject } from "./is-object"
export function pruneNullishValues<T>(input: T): PruneNullishValues<T> {
if (!isObject(input)) {
return input as PruneNullishValues<T>
}
if (Array.isArray(input)) {
return input
.filter(Boolean)
.map(
(item) => pruneNullishValues(item) as unknown,
) as PruneNullishValues<T>
return input.filter(Boolean).map((item) => pruneNullishValues(item)) as any
}
const result: Record<string, unknown> = {}
if (!isObject(input)) {
return input as any
}
const result: any = {}
for (const [key, value] of Object.entries(input)) {
if (value != undefined) {
result[key] = pruneNullishValues(value)
}
}
return result as PruneNullishValues<T>
return result
}
export type PruneNullishValues<Input> = Input extends object

View File

@@ -1,5 +1,5 @@
import { toError } from "./to-error.js"
import { setTimeout } from "node:timers/promises"
import { toError } from "./to-error.js"
export async function rejectAfter(
timeMs: number,

View File

@@ -7,7 +7,7 @@ export async function retryWithTimeout<T>(
callback: () => Promise<T> | T,
): Promise<T> {
const startTime = Date.now()
// eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await callback()

View File

@@ -1,4 +0,0 @@
import { type LooseOmit, type LoosePick, typeEquals } from "./types.ts"
typeEquals<LoosePick<{ a: 1; b: 2 }, "a">, { a: 1 }>(true)
typeEquals<LooseOmit<{ a: 1; b: 2 }, "a">, { b: 2 }>(true)

View File

@@ -1,21 +1,11 @@
import { raise } from "./raise.ts"
export type MaybePromise<T> = T | PromiseLike<T>
export type MaybePromise<T> = T | Promise<T>
export type ValueOf<Type> = Type extends ReadonlyArray<infer Value>
? Value
: Type[keyof Type]
export type LoosePick<Shape, Keys extends PropertyKey> = Simplify<{
[Key in Extract<Keys, keyof Shape>]: Shape[Key]
}>
export type UnknownRecord = Record<PropertyKey, unknown>
export type LooseOmit<Shape, Keys extends PropertyKey> = Simplify<{
[Key in Exclude<keyof Shape, Keys>]: Shape[Key]
}>
export type Simplify<T> = { [Key in keyof T]: T[Key] } & NonNullable<unknown>
export const typeEquals = <A, B>(
_result: A extends B ? (B extends A ? true : false) : false,
) => raise("typeEquals() should not be called at runtime")
export type LoosePick<Shape, Keys extends PropertyKey> = {
[Key in Keys]: Shape extends Record<Key, infer Value> ? Value : never
}

View File

@@ -1,10 +1,9 @@
import { setTimeout } from "node:timers/promises"
import type { MaybePromise } from "./types.ts"
const maxTime = 1000
export async function waitFor<Result>(
predicate: () => MaybePromise<Result>,
predicate: () => Result,
): Promise<Awaited<Result>> {
const startTime = Date.now()
let lastError: unknown
@@ -18,6 +17,5 @@ export async function waitFor<Result>(
}
}
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw lastError ?? new Error("Timeout")
}

View File

@@ -7,7 +7,7 @@ export function withLoggedMethodCalls<T extends object>(value: T) {
if (typeof value !== "function") {
return value
}
return (...values: unknown[]) => {
return (...values: any[]) => {
console.info(
`${String(property)}(${values
.map((value) =>
@@ -17,7 +17,7 @@ export function withLoggedMethodCalls<T extends object>(value: T) {
)
.join(", ")})`,
)
return value.apply(target, values) as unknown
return value.apply(target, values)
}
},
}) as T

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,22 +1,5 @@
# reacord
## 0.5.3
### Patch Changes
- 104b175: ensure message is edited from arbitrary component updates
- 156cf90: fix interaction handling
- 0bab505: fix DJS deprecation warning on isStringSelectMenu
- d76f316: ensure action rows handle child interactions
## 0.5.2
### Patch Changes
- 9813a01: import react-reconciler/constants.js for esm
ESM projects which tried to import reacord would fail due to the lack of .js on this import
## 0.5.1
### Patch Changes

View File

@@ -1 +0,0 @@
/// <reference types="@total-typescript/ts-reset" />

View File

@@ -1,102 +0,0 @@
import type { ReactNode } from "react"
import type { ReacordInstance } from "./instance"
/** @category Component Event */
export interface 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 interface ChannelInfo {
id: string
name?: string
topic?: string
nsfw?: boolean
lastMessageId?: string
ownerId?: string
parentId?: string
rateLimitPerUser?: number
}
/** @category Component Event */
export interface MessageInfo {
id: string
channelId: string
authorId: string
member?: GuildMemberInfo
content: string
timestamp: string
editedTimestamp?: string
tts: boolean
mentionEveryone: boolean
/** The IDs of mentioned users */
mentions: string[]
}
/** @category Component Event */
export interface GuildInfo {
id: string
name: string
member: GuildMemberInfo
}
/** @category Component Event */
export interface 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 interface UserInfo {
id: string
username: string
discriminator: string
tag: string
avatarUrl: string | null
accentColor?: number
}

View File

@@ -1,56 +0,0 @@
import type { ReactNode } from "react"
import { ReacordElement } from "../../internal/element.js"
import type { MessageOptions } from "../../internal/message"
import { Node } from "../../internal/node.js"
import type { ComponentInteraction } from "../../internal/interaction.js"
/**
* Props for an action row
*
* @category Action Row
*/
export interface ActionRowProps {
children?: ReactNode
}
/**
* An action row is a top-level container for message components.
*
* You don't need to use this; Reacord automatically creates action rows for
* you. But this can be useful if you want a specific layout.
*
* ```tsx
* // put buttons on two separate rows
* <ActionRow>
* <Button label="First" onClick={handleFirst} />
* </ActionRow>
* <Button label="Second" onClick={handleSecond} />
* ```
*
* @category Action Row
* @see https://discord.com/developers/docs/interactions/message-components#action-rows
*/
export function ActionRow(props: ActionRowProps) {
return (
<ReacordElement props={props} createNode={() => new ActionRowNode(props)}>
{props.children}
</ReacordElement>
)
}
class ActionRowNode extends Node<ActionRowProps> {
override modifyMessageOptions(options: MessageOptions): void {
options.actionRows.push([])
for (const child of this.children) {
child.modifyMessageOptions(options)
}
}
handleComponentInteraction(interaction: ComponentInteraction) {
for (const child of this.children) {
if (child.handleComponentInteraction(interaction)) {
return true
}
}
return false
}
}

View File

@@ -1,24 +0,0 @@
import type { ReactNode } from "react"
/**
* Common props between button-like components
*
* @category Button
*/
export interface ButtonSharedProps {
/** The text on the button. Rich formatting (markdown) is not supported here. */
label?: ReactNode
/** When true, the button will be slightly faded, and cannot be clicked. */
disabled?: boolean
/**
* Renders an emoji to the left of the text. Has to be a literal emoji
* character (e.g. 🍍), or an emoji code, like
* `<:plus_one:778531744860602388>`.
*
* To get an emoji code, type your emoji in Discord chat with a backslash `\`
* in front. The bot has to be in the emoji's guild to use it.
*/
emoji?: string
}

View File

@@ -1,69 +0,0 @@
import { randomUUID } from "node:crypto"
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 @typescript-eslint/class-literal-property-style
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<Record<string, never>> {}

View File

@@ -1,36 +0,0 @@
import type { ReactNode } 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 interface 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<Record<string, never>> {}

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,41 +0,0 @@
import type { ReactNode } 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 interface 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<Record<string, never>> {}
class FieldValueNode extends Node<Record<string, never>> {}

View File

@@ -1,40 +0,0 @@
import type { ReactNode } 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 interface 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<Record<string, never>> {}

View File

@@ -1,24 +0,0 @@
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/** @category Embed */
export interface 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 { EmbedProps } from "./embed"
import type { Except, SnakeCasedPropertiesDeep } from "type-fest"
export type EmbedOptions = SnakeCasedPropertiesDeep<
Except<EmbedProps, "timestamp" | "children"> & {
timestamp?: string
}
>

View File

@@ -1,24 +0,0 @@
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/** @category Embed */
export interface 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,31 +0,0 @@
import type { ReactNode } 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 interface 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<Record<string, never>> {}

View File

@@ -1,62 +0,0 @@
import { snakeCaseDeep } from "@reacord/helpers/convert-object-property-case"
import { omit } from "@reacord/helpers/omit"
import type React from "react"
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 interface 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,38 +0,0 @@
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<Record<string, never>> {}

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<Record<string, never>> {}
export class OptionDescriptionNode extends Node<Record<string, never>> {}

View File

@@ -1,56 +0,0 @@
import type { ReactNode } from "react"
import { ReacordElement } from "../../internal/element"
import {
OptionDescriptionNode,
OptionLabelNode,
OptionNode,
} from "./option-node"
/** @category Select */
export interface OptionProps {
/** The internal value of this option */
value: string
/** The text shown to the user. This takes priority over `children` */
label?: ReactNode
/** The text shown to the user */
children?: ReactNode
/** Description for the option, shown to the user */
description?: ReactNode
/**
* Renders an emoji to the left of the text.
*
* Has to be a literal emoji character (e.g. 🍍), or an emoji code, like
* `<:plus_one:778531744860602388>`.
*
* To get an emoji code, type your emoji in Discord chat with a backslash `\`
* in front. The bot has to be in the emoji's guild to use it.
*/
emoji?: string
}
/** @category Select */
export function Option({
label,
children,
description,
...props
}: OptionProps) {
return (
<ReacordElement props={props} createNode={() => new OptionNode(props)}>
{(label !== undefined || children !== undefined) && (
<ReacordElement props={{}} createNode={() => new OptionLabelNode({})}>
{label ?? children}
</ReacordElement>
)}
{description !== undefined && (
<ReacordElement
props={{}}
createNode={() => new OptionDescriptionNode({})}
>
{description}
</ReacordElement>
)}
</ReacordElement>
)
}

View File

@@ -1,152 +0,0 @@
import { isInstanceOf } from "@reacord/helpers/is-instance-of"
import { randomUUID } from "node:crypto"
import type { ReactNode } from "react"
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 { omit } from "@reacord/helpers/omit.js"
/** @category Select */
export interface SelectProps {
children?: ReactNode
/** Sets the currently selected value */
value?: string
/** Sets the currently selected values, for use with `multiple` */
values?: string[]
/** The text shown when no value is selected */
placeholder?: string
/** Set to true to allow multiple selected values */
multiple?: boolean
/**
* With `multiple`, the minimum number of values that can be selected. When
* `multiple` is false or not defined, this is always 1.
*
* This only limits the number of values that can be received by the user.
* This does not limit the number of values that can be displayed by you.
*/
minValues?: number
/**
* With `multiple`, the maximum number of values that can be selected. When
* `multiple` is false or not defined, this is always 1.
*
* This only limits the number of values that can be received by the user.
* This does not limit the number of values that can be displayed by you.
*/
maxValues?: number
/**
* When true, the select will be slightly faded, and cannot be interacted
* with.
*/
disabled?: boolean
/**
* Called when the user inputs a selection. Receives the entire select change
* event, which can be used to create new replies, etc.
*/
onChange?: (event: SelectChangeEvent) => void
/**
* Convenience shorthand for `onChange`, which receives the first selected
* value.
*/
onChangeValue?: (value: string, event: SelectChangeEvent) => void
/** Convenience shorthand for `onChange`, which receives all selected values. */
onChangeMultiple?: (values: string[], event: SelectChangeEvent) => void
}
/** @category Select */
export type SelectChangeEvent = ComponentEvent & {
values: string[]
}
/**
* See [the select menu guide](/guides/select-menu) for a usage example.
*
* @category Select
*/
export function Select(props: SelectProps) {
return (
<ReacordElement props={props} createNode={() => new SelectNode(props)}>
{props.children}
</ReacordElement>
)
}
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,
...props
} = omit(this.props, [
"children",
"onChange",
"onChangeValue",
"onChangeMultiple",
])
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

@@ -1,20 +0,0 @@
import type { ReactNode } from "react"
/**
* Represents an interactive message, which can later be replaced or deleted.
*
* @category Core
*/
export interface 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,408 +0,0 @@
import { safeJsonStringify } from "@reacord/helpers/json"
import { pick } from "@reacord/helpers/pick"
import { pruneNullishValues } from "@reacord/helpers/prune-nullish-values"
import { raise } from "@reacord/helpers/raise"
import * as Discord from "discord.js"
import type { ReactNode } from "react"
import type { Except } from "type-fest"
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.isStringSelectMenu()) {
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)
},
followUp: async (options) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(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),
authorId: interaction.message.author.id,
mentionEveryone: interaction.message.mentions.everyone,
}
: 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(),
color: interaction.member.displayColor,
displayAvatarUrl: interaction.member.displayAvatarURL(),
}
: 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) => {
if (interaction.deferred || interaction.replied) {
await interaction.message.edit(getDiscordMessageOptions(options))
} else {
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)
},
followUp: async (options) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(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.isStringSelectMenu()) {
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 = {
content: reacordOptions.content || undefined,
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,
}
}
// future proofing
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (component.type === "select") {
return {
...component,
type: Discord.ComponentType.SelectMenu,
options: component.options.map((option) => ({
...option,
default: component.values?.includes(option.value),
})),
}
}
component satisfies never
throw new Error(
`Invalid component type ${safeJsonStringify(component)}}`,
)
},
),
})),
}
if (!options.content && !options.embeds.length) {
options.content = "_ _"
}
return options
}

View File

@@ -1,85 +0,0 @@
import type { ReactNode } from "react"
import type { ComponentInteraction } from "../internal/interaction.js"
import { reconciler } from "../internal/reconciler.js"
import type { Renderer } from "../internal/renderers/renderer.js"
import { InstanceProvider } from "./instance-context.js"
import type { ReacordInstance } from "./instance.js"
/** @category Core */
export interface 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.renderers[0]) {
this.deactivate(this.renderers[0])
}
this.renderers.push(renderer)
const container: unknown = reconciler.createContainer(
renderer,
0,
null,
false,
null,
"reacord",
() => {},
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 interface Channel {
send(message: MessageOptions): Promise<Message>
}

View File

@@ -1,39 +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: Array<NonNullable<unknown>>) => 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,11 +0,0 @@
import type { Node } from "./node"
import type { ReactNode } from "react"
import React from "react"
export function ReacordElement<Props>(props: {
props: Props
createNode: () => Node<Props>
children?: ReactNode
}) {
return React.createElement("reacord-element", props)
}

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 interface 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 { EmbedOptions } from "../core/components/embed-options"
import type { SelectProps } from "../core/components/select"
import { last } from "@reacord/helpers/last"
import type { Except } from "type-fest"
export interface MessageOptions {
content: string
embeds: EmbedOptions[]
actionRows: ActionRow[]
}
export type ActionRow = ActionRowItem[]
export type ActionRowItem =
| MessageButtonOptions
| MessageLinkOptions
| MessageSelectOptions
export interface MessageButtonOptions {
type: "button"
customId: string
label?: string
style?: "primary" | "secondary" | "success" | "danger"
disabled?: boolean
emoji?: string
}
export interface 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 interface MessageSelectOptionOptions {
label: string
value: string
description?: string
emoji?: string
}
export interface 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,21 +0,0 @@
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) {
// noop
}
handleComponentInteraction(_interaction: ComponentInteraction): boolean {
return false
}
get text(): string {
return [...this.children].map((child) => child.text).join("")
}
}

View File

@@ -1,107 +0,0 @@
import { raise } from "@reacord/helpers/raise.js"
import type { HostConfig } from "react-reconciler"
import ReactReconciler from "react-reconciler"
import { DefaultEventPriority } from "react-reconciler/constants.js"
import { Node } from "./node.js"
import type { Renderer } from "./renderers/renderer"
import { TextNode } from "./text-node.js"
const config: HostConfig<
string, // Type,
Record<string, unknown>, // Props,
Renderer, // Container,
Node<unknown>, // Instance,
TextNode, // TextInstance,
never, // SuspenseInstance,
never, // HydratableInstance,
never, // PublicInstance,
never, // HostContext,
true, // UpdatePayload,
never, // ChildSet,
number, // TimeoutHandle,
number // NoTimeout,
> = {
supportsMutation: true,
supportsPersistence: false,
supportsHydration: false,
isPrimaryRenderer: true,
scheduleTimeout: global.setTimeout,
cancelTimeout: global.clearTimeout,
noTimeout: -1,
getRootHostContext: () => null,
getChildHostContext: (parentContext) => parentContext,
createInstance: (type, props) => {
if (type !== "reacord-element") {
raise(`Unknown element type: ${type}`)
}
if (typeof props.createNode !== "function") {
raise(`Missing createNode function`)
}
const node: unknown = props.createNode(props.props)
if (!(node instanceof Node)) {
raise(`createNode function did not return a Node`)
}
return node
},
createTextInstance: (text) => new TextNode(text),
shouldSetTextContent: () => false,
detachDeletedInstance: (_instance) => {},
beforeActiveInstanceBlur: () => {},
afterActiveInstanceBlur: () => {},
getInstanceFromNode: (_node: unknown) => null,
getInstanceFromScope: (_scopeInstance: unknown) => null,
clearContainer: (renderer) => {
renderer.nodes.clear()
},
appendChildToContainer: (renderer, child) => {
renderer.nodes.add(child)
},
removeChildFromContainer: (renderer, child) => {
renderer.nodes.remove(child)
},
insertInContainerBefore: (renderer, child, before) => {
renderer.nodes.addBefore(child, before)
},
appendInitialChild: (parent, child) => {
parent.children.add(child)
},
appendChild: (parent, child) => {
parent.children.add(child)
},
removeChild: (parent, child) => {
parent.children.remove(child)
},
insertBefore: (parent, child, before) => {
parent.children.addBefore(child, before)
},
prepareUpdate: () => true,
commitUpdate: (node, payload, type, oldProps, newProps) => {
node.props = newProps.props
},
commitTextUpdate: (node, oldText, newText) => {
node.props = newText
},
prepareForCommit: () => null,
resetAfterCommit: (renderer) => {
renderer.render()
},
prepareScopeUpdate: (_scopeInstance: unknown, _instance: unknown) => {},
preparePortalMount: () => raise("Portals are not supported"),
getPublicInstance: () => raise("Refs are currently not supported"),
finalizeInitialChildren: () => false,
getCurrentEventPriority: () => DefaultEventPriority,
}
export const reconciler = ReactReconciler(config)

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,117 +0,0 @@
import { Container } from "../container.js"
import type { ComponentInteraction } from "../interaction"
import type { Message, MessageOptions } from "../message"
import type { Node } from "../node.js"
import { Subject } from "rxjs"
import { concatMap } from "rxjs/operators"
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) {
for (const node of this.nodes) {
if (node.handleComponentInteraction(interaction)) {
this.componentInteraction = interaction
setTimeout(() => {
this.updates.next({ action: "deferUpdate", interaction })
}, 500)
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

@@ -2,7 +2,7 @@
"name": "reacord",
"type": "module",
"description": "Create interactive Discord messages using React.",
"version": "0.5.3",
"version": "0.5.1",
"types": "./dist/main.d.ts",
"homepage": "https://reacord.mapleleaf.dev",
"repository": "https://github.com/itsMapleLeaf/reacord.git",
@@ -20,7 +20,6 @@
"reacord"
],
"files": [
"library",
"dist",
"README.md",
"LICENSE"
@@ -29,7 +28,7 @@
".": {
"import": "./dist/main.js",
"require": "./dist/main.cjs",
"types": "./library/main.ts"
"types": "./dist/main.d.ts"
},
"./package.json": {
"import": "./package.json",
@@ -37,19 +36,30 @@
}
},
"scripts": {
"build": "cpy ../../README.md ../../LICENSE . && tsup library/main.ts --target node16 --format cjs,esm --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 -b"
"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": "^20.7.0",
"@types/react": "^18.2.23",
"@types/react-reconciler": "^0.28.5",
"@types/node": "*",
"@types/react": "*",
"@types/react-reconciler": "*",
"discord-api-types": "^0.37.1",
"react-reconciler": "^0.29.0",
"rxjs": "^7.8.1"
"rxjs": "^7.5.6"
},
"peerDependencies": {
"discord.js": "^14",
@@ -62,19 +72,26 @@
},
"devDependencies": {
"@reacord/helpers": "workspace:*",
"@types/lodash-es": "^4.17.9",
"c8": "^8.0.1",
"cpy-cli": "^5.0.0",
"discord.js": "^14.13.0",
"dotenv": "^16.3.1",
"@types/lodash-es": "^4.17.6",
"@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": "^3.0.1",
"prettier": "^3.0.3",
"nodemon": "^2.0.19",
"ora": "^6.1.2",
"prettier": "^2.7.1",
"pretty-ms": "^8.0.0",
"react": "^18.2.0",
"tsup": "^7.2.0",
"tsx": "^3.13.0",
"type-fest": "^4.3.2"
"release-it": "^15.2.0",
"ts-morph": "^15.1.0",
"tsup": "^6.2.1",
"tsx": "^3.8.0",
"type-fest": "^2.18.0",
"typescript": "^4.7.4"
},
"resolutions": {
"esbuild": "latest"
},
"release-it": {
"git": {

View File

@@ -1,144 +0,0 @@
import { raise } from "@reacord/helpers/raise.js"
import {
Button,
Link,
Option,
ReacordDiscordJs,
Select,
useInstance,
} from "../library/main.js"
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"
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 ?? raise("TEST_GUILD_ID not defined"),
)
const category = await guild.channels.fetch(
process.env.TEST_CATEGORY_ID ?? raise("TEST_CATEGORY_ID not defined"),
)
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) => 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

@@ -0,0 +1,39 @@
import type { ReactNode } from "react"
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* Props for an action row
* @category Action Row
*/
export type ActionRowProps = {
children?: ReactNode
}
/**
* An action row is a top-level container for message components.
*
* You don't need to use this; Reacord automatically creates action rows for you.
* But this can be useful if you want a specific layout.
*
* ```tsx
* // put buttons on two separate rows
* <ActionRow>
* <Button label="First" onClick={handleFirst} />
* </ActionRow>
* <Button label="Second" onClick={handleSecond} />
* ```
*
* @category Action Row
* @see https://discord.com/developers/docs/interactions/message-components#action-rows
*/
export function ActionRow(props: ActionRowProps) {
return (
<ReacordElement props={{}} createNode={() => new ActionRowNode({})}>
{props.children}
</ReacordElement>
)
}
export class ActionRowNode extends Node<{}> {}

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from "react"
/**
* Common props between button-like components
* @category Button
*/
export type ButtonSharedProps = {
/** The text on the button. Rich formatting (markdown) is not supported here. */
label?: ReactNode
/** When true, the button will be slightly faded, and cannot be clicked. */
disabled?: boolean
/**
* Renders an emoji to the left of the text.
* Has to be a literal emoji character (e.g. 🍍),
* or an emoji code, like `<:plus_one:778531744860602388>`.
*
* To get an emoji code, type your emoji in Discord chat
* with a backslash `\` in front.
* The bot has to be in the emoji's guild to use it.
*/
emoji?: string
}

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 type { ReacordInstance } from "./instance.js"
import { raise } from "@reacord/helpers/raise.js"
import { raise } from "@reacord/helpers/raise"
import * as React from "react"
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

@@ -0,0 +1,64 @@
import type { ReactNode } from "react"
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Select
*/
export type OptionProps = {
/** The internal value of this option */
value: string
/** The text shown to the user. This takes priority over `children` */
label?: ReactNode
/** The text shown to the user */
children?: ReactNode
/** Description for the option, shown to the user */
description?: ReactNode
/**
* Renders an emoji to the left of the text.
*
* Has to be a literal emoji character (e.g. 🍍),
* or an emoji code, like `<:plus_one:778531744860602388>`.
*
* To get an emoji code, type your emoji in Discord chat
* with a backslash `\` in front.
* The bot has to be in the emoji's guild to use it.
*/
emoji?: string
}
/**
* @category Select
*/
export function Option({
label,
children,
description,
...props
}: OptionProps) {
return (
<ReacordElement props={props} createNode={() => new OptionNode(props)}>
{(label !== undefined || children !== undefined) && (
<ReacordElement props={{}} createNode={() => new OptionLabelNode({})}>
{label || children}
</ReacordElement>
)}
{description !== undefined && (
<ReacordElement
props={{}}
createNode={() => new OptionDescriptionNode({})}
>
{description}
</ReacordElement>
)}
</ReacordElement>
)
}
export class OptionNode extends Node<
Omit<OptionProps, "children" | "label" | "description">
> {}
export class OptionLabelNode extends Node<{}> {}
export class OptionDescriptionNode extends Node<{}> {}

View File

@@ -0,0 +1,11 @@
import type { ReactNode } from "react"
import React from "react"
import type { Node } from "../node"
export function ReacordElement<Props>(props: {
props: Props
createNode: () => Node<Props>
children?: ReactNode
}) {
return React.createElement("reacord-element", props)
}

View File

@@ -0,0 +1,108 @@
/* 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 { Node, TextNode } from "../node"
import type { ReacordInstancePrivate } from "../reacord-instance"
export const reconciler = ReactReconciler<
string, // Type,
Record<string, unknown>, // Props,
ReacordInstancePrivate, // Container,
Node, // Instance,
TextNode, // TextInstance,
never, // SuspenseInstance,
never, // HydratableInstance,
never, // PublicInstance,
never, // HostContext,
true, // UpdatePayload,
never, // ChildSet,
number, // TimeoutHandle,
number // NoTimeout,
>({
supportsMutation: true,
supportsPersistence: false,
supportsHydration: false,
isPrimaryRenderer: true,
scheduleTimeout: global.setTimeout,
cancelTimeout: global.clearTimeout,
noTimeout: -1,
// eslint-disable-next-line unicorn/no-null
getRootHostContext: () => null,
getChildHostContext: (parentContext) => parentContext,
createInstance: (type, props) => {
if (type !== "reacord-element") {
raise(`Unknown element type: ${type}`)
}
if (typeof props.createNode !== "function") {
raise(`Missing createNode function`)
}
const node = props.createNode(props.props)
if (!(node instanceof Node)) {
raise(`createNode function did not return a Node`)
}
return node
},
createTextInstance: (text) => new TextNode({ text }),
shouldSetTextContent: () => false,
detachDeletedInstance: (instance) => {},
beforeActiveInstanceBlur: () => {},
afterActiveInstanceBlur: () => {},
// eslint-disable-next-line unicorn/no-null
getInstanceFromNode: (node: any) => null,
// eslint-disable-next-line unicorn/no-null
getInstanceFromScope: (scopeInstance: any) => null,
clearContainer: (instance) => {
instance.tree.clear()
},
appendChildToContainer: (instance, child) => {
instance.tree.add(child)
},
removeChildFromContainer: (instance, child) => {
instance.tree.remove(child)
},
insertInContainerBefore: (instance, child, before) => {
instance.tree.insertBefore(child, before)
},
appendInitialChild: (parent, child) => {
parent.add(child)
},
appendChild: (parent, child) => {
parent.add(child)
},
removeChild: (parent, child) => {
parent.remove(child)
},
insertBefore: (parent, child, before) => {
parent.insertBefore(child, before)
},
prepareUpdate: () => true,
commitUpdate: (node, payload, type, oldProps, newProps) => {
node.props = newProps.props
},
commitTextUpdate: (node, oldText, newText) => {
node.props.text = newText
},
// eslint-disable-next-line unicorn/no-null
prepareForCommit: () => null,
resetAfterCommit: (renderer) => {
void renderer.update(renderer.tree)
},
prepareScopeUpdate: (scopeInstance: any, instance: any) => {},
preparePortalMount: () => raise("Portals are not supported"),
getPublicInstance: () => raise("Refs are currently not supported"),
finalizeInitialChildren: () => false,
getCurrentEventPriority: () => DefaultEventPriority,
})

View File

@@ -0,0 +1,95 @@
import type { APIMessageComponentSelectMenuInteraction } from "discord.js"
import { randomUUID } from "node:crypto"
import type { ReactNode } from "react"
import React from "react"
import { Node } from "../node"
import type { ComponentEvent } from "./component-event"
import { ReacordElement } from "./reacord-element"
/**
* @category Select
*/
export type SelectProps = {
children?: ReactNode
/** Sets the currently selected value */
value?: string
/** Sets the currently selected values, for use with `multiple` */
values?: string[]
/** The text shown when no value is selected */
placeholder?: string
/** Set to true to allow multiple selected values */
multiple?: boolean
/**
* With `multiple`, the minimum number of values that can be selected.
* When `multiple` is false or not defined, this is always 1.
*
* This only limits the number of values that can be received by the user.
* This does not limit the number of values that can be displayed by you.
*/
minValues?: number
/**
* With `multiple`, the maximum number of values that can be selected.
* When `multiple` is false or not defined, this is always 1.
*
* This only limits the number of values that can be received by the user.
* This does not limit the number of values that can be displayed by you.
*/
maxValues?: number
/** When true, the select will be slightly faded, and cannot be interacted with. */
disabled?: boolean
/**
* Called when the user inputs a selection.
* Receives the entire select change event,
* which can be used to create new replies, etc.
*/
onChange?: (event: SelectChangeEvent) => void
/**
* Convenience shorthand for `onChange`, which receives the first selected value.
*/
onChangeValue?: (value: string, event: SelectChangeEvent) => void
/**
* Convenience shorthand for `onChange`, which receives all selected values.
*/
onChangeMultiple?: (values: string[], event: SelectChangeEvent) => void
}
/**
* @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
}
/**
* See [the select menu guide](/guides/select-menu) for a usage example.
* @category Select
*/
export function Select(props: SelectProps) {
return (
<ReacordElement props={props} createNode={() => new SelectNode(props)}>
{props.children}
</ReacordElement>
)
}
export class SelectNode extends Node<SelectProps> {
readonly customId = randomUUID()
}

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,40 +1,60 @@
import { test } from "vitest"
import { ActionRow, Button, Select } from "../library/main"
import { ReacordTester } from "./test-adapter"
const testing = new ReacordTester()
import { ComponentType } from "discord.js"
import React from "react"
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,12 +1,16 @@
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", () => {
const require = createRequire(import.meta.url)
expect(() => require("../dist/main.cjs") as unknown).not.toThrow()
expect(() => require("../dist/main.cjs")).not.toThrow()
})

View File

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

View File

@@ -1,4 +1,5 @@
import { test } from "vitest"
import React from "react"
import { expect, test } from "vitest"
import {
Embed,
EmbedAuthor,
@@ -7,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" />
@@ -32,17 +32,15 @@ test("kitchen sink", async () => {
/>
</Embed>
</>,
[
{
actionRows: [],
content: "",
embeds: [
{
)
expect(message.embeds.map((e) => e.toJSON())).toEqual([
expect.objectContaining({
description: "description text",
author: {
author: expect.objectContaining({
icon_url: "https://example.com/author.png",
name: "author",
},
}),
color: 0xfe_ee_ef,
fields: [
{
@@ -51,66 +49,66 @@ test("kitchen sink", async () => {
value: "field value",
},
{
inline: false,
name: "block field",
value: "block field value",
},
],
footer: {
footer: expect.objectContaining({
icon_url: "https://example.com/footer.png",
text: "footer text",
},
image: {
}),
image: expect.objectContaining({
url: "https://example.com/image.png",
},
thumbnail: {
}),
thumbnail: expect.objectContaining({
url: "https://example.com/thumbnail.png",
},
timestamp: now.toISOString(),
}),
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" />
@@ -121,43 +119,41 @@ test("field variants", async () => {
<EmbedField name="field name" />
</Embed>
</>,
[
{
content: "",
actionRows: [],
embeds: [
{
)
expect(message.embeds.map((e) => e.toJSON())).toEqual([
expect.objectContaining({
fields: [
{
name: "field name",
value: "field value",
inline: false,
},
{
inline: true,
name: "field name",
value: "field value",
inline: true,
},
{
inline: true,
name: "field name",
value: "field value",
inline: true,
},
{
name: "field name",
value: "",
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" />
@@ -175,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"
@@ -240,35 +228,33 @@ test("embed props", async () => {
{ name: "block field", value: "block field value" },
]}
/>,
[
{
content: "",
actionRows: [],
embeds: [
{
)
expect(message.embeds.map((e) => e.toJSON())).toEqual([
expect.objectContaining({
title: "title text",
description: "description text",
url: "https://example.com/",
color: 0xfe_ee_ef,
timestamp: now.toISOString(),
author: {
author: expect.objectContaining({
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: {
}),
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" },
{ name: "block field", value: "block field value", inline: false },
],
},
],
},
],
)
}),
])
expect(new Date(message.embeds[0]!.timestamp!)).toEqual(now)
})

View File

@@ -1,3 +1,2 @@
import { test } from "vitest"
test.todo("ephemeral reply")

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

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