68 Commits

Author SHA1 Message Date
Darius
a00fbc0631 Merge pull request #42 from itsMapleLeaf/changeset-release/main 2023-10-28 14:53:10 -05:00
github-actions[bot]
a713f17a5c Version Packages 2023-10-28 19:49:36 +00:00
Darius
44795cd7cc Merge pull request #41 from itsMapleLeaf/dev 2023-10-28 14:49:01 -05:00
itsMapleLeaf
17978a5252 node 16 is EOL 2023-10-28 14:47:01 -05:00
itsMapleLeaf
95fb342183 remove format and fix lint:prettier 2023-10-28 14:46:25 -05:00
itsMapleLeaf
0772ca4502 fix test command interaction 2023-10-28 14:45:03 -05:00
itsMapleLeaf
11153dfe0f breaking: more descriptive component event types 2023-10-28 14:39:16 -05:00
itsMapleLeaf
fb0a997855 changeset 2023-10-28 14:34:13 -05:00
itsMapleLeaf
da1c62f2f0 public interface tweaks and such 2023-10-28 14:34:09 -05:00
Darius
cdc22b7916 Merge pull request #40 from domin-mnd/main 2023-10-28 13:04:33 -05:00
Domin-MND
7fee69c8ae fix select-menu guide 2023-10-27 16:09:18 +03:00
Domin-MND
c2e5dc04dd fix api guides 2023-10-27 16:06:00 +03:00
Domin-MND
390da4cab6 remove initial content for create methods 2023-10-24 19:58:48 +03:00
Domin-MND
def0c46f13 fix monorepo formatting 2023-10-23 23:25:44 +03:00
Domin-MND
8b6e283810 update guides 2023-10-23 23:22:25 +03:00
Domin-MND
13fcf7ddc9 match test adapter syntax 2023-10-23 22:25:06 +03:00
Domin-MND
ce12351a24 fix formatting 2023-10-23 22:08:08 +03:00
Domin-MND
73bb098774 add options for component event 2023-10-23 22:05:05 +03:00
Domin-MND
4ee4d4ab91 add options for component event 2023-10-23 22:02:33 +03:00
Domin-MND
f998a0e09a fix djs manual test 2023-10-23 12:24:24 +03:00
Domin-MND
453192cc96 cleanup 2023-10-23 11:51:59 +03:00
Domin-MND
d387f669ab more descriptive djs adapter methods 2023-10-21 11:16:58 +03:00
Darius
9aec87ae9f Merge pull request #39 from domin-mnd/main 2023-10-19 13:05:01 -05:00
Domin-MND
65d1d68bb0 fix id raising 2023-10-19 16:37:51 +03:00
Domin-MND
dfb7562c97 use reply renderer for ephermalReply 2023-10-18 21:48:38 +03:00
Domin-MND
9e2be6c2e0 add opts argument support 2023-10-18 21:39:17 +03:00
Domin-MND
d078995516 deprecate ephemeralReply in adapters 2023-10-18 20:59:14 +03:00
Darius
82b3575f2d Merge pull request #37 from itsMapleLeaf/changeset-release/main 2023-10-10 10:53:03 -05:00
github-actions[bot]
82b811c98b Version Packages 2023-10-10 15:51:06 +00:00
itsMapleLeaf
3a786310b1 upgrades 2023-10-10 10:50:15 -05:00
itsMapleLeaf
ced48a3ecb distribute .d.ts files 2023-10-10 10:47:31 -05:00
itsMapleLeaf
37b75a99e2 use type:module in helpers 2023-10-10 10:45:50 -05:00
itsMapleLeaf
f2f215d6b9 fix banner in readme 2023-09-28 12:47:39 -05:00
Darius
1f67e7c263 Merge pull request #35 from itsMapleLeaf/changeset-release/main 2023-09-28 12:33:08 -05:00
github-actions[bot]
d4f1bb4d4b Version Packages 2023-09-28 17:23:24 +00:00
Darius
47c9b75940 Merge pull request #34 from itsMapleLeaf/fix-type-definitions 2023-09-28 12:22:52 -05:00
itsMapleLeaf
41c87e3dcc fix typedefs 2023-09-28 12:20:58 -05:00
Darius
b210670b2a Merge pull request #31 from itsMapleLeaf/changeset-release/main 2023-09-27 23:21:57 -05:00
github-actions[bot]
2b9110bf2c Version Packages 2023-09-28 04:20:36 +00:00
Darius
5d4dde4e0c Merge pull request #30 from itsMapleLeaf/renderer-bug-fixes 2023-09-27 23:20:04 -05:00
itsMapleLeaf
31baa23076 format astro files 2023-09-27 23:18:36 -05:00
itsMapleLeaf
d76f316bb7 ensure action rows handle child interactions 2023-09-27 23:15:24 -05:00
itsMapleLeaf
47b0645a90 fix linter warnings 2023-09-27 23:09:21 -05:00
itsMapleLeaf
0bab505994 fix deprecated method 2023-09-27 23:09:21 -05:00
itsMapleLeaf
104b175931 edit if deferred or replied 2023-09-27 23:08:41 -05:00
itsMapleLeaf
156cf90919 set component interaction in loop
...only when an interaction was handled
2023-09-27 23:08:35 -05:00
Darius
b463ce3cf4 Merge pull request #27 from itsMapleLeaf/update-lint 2023-09-27 13:32:49 -05:00
itsMapleLeaf
576dd2e35e manual fixes, disable some errors 2023-09-27 13:29:20 -05:00
itsMapleLeaf
0d4294ee8c upgrade deps + remove unneeded 2023-09-27 12:33:40 -05:00
itsMapleLeaf
25fcc53d91 only list formatted files 2023-09-27 12:26:07 -05:00
itsMapleLeaf
34bc293df5 Merge branch 'update-lint' of https://github.com/itsMapleLeaf/reacord into update-lint 2023-09-27 12:24:00 -05:00
itsMapleLeaf
b7b237f2f5 less wordy name 2023-09-27 12:23:56 -05:00
itsMapleLeaf
e2c3de4fae Apply automatic changes 2023-09-27 17:23:41 +00:00
itsMapleLeaf
ffe0a86a33 merge and rename workflows 2023-09-27 12:23:24 -05:00
itsMapleLeaf
6ce9241080 run other scripts + fix always run 2023-09-27 12:22:09 -05:00
itsMapleLeaf
5d96d517df add cache and autocommit 2023-09-27 12:10:05 -05:00
itsMapleLeaf
2c706f6791 update lint configs and scripts 2023-09-27 12:07:32 -05:00
itsMapleLeaf
2abb61493e Merge branch 'rewrite' 2023-08-16 20:44:42 -05:00
itsMapleLeaf
3db1013b74 also sync before lint 2023-08-16 20:44:35 -05:00
itsMapleLeaf
3e2c6ba5d6 rename test job 2023-08-16 20:43:10 -05:00
itsMapleLeaf
0172534d13 actually remove tailwind typography 2023-08-16 20:41:59 -05:00
itsMapleLeaf
1a49423beb run astro sync before typecheck 2023-08-16 20:41:06 -05:00
itsMapleLeaf
3824859352 add github profile link 2023-08-16 20:34:35 -05:00
itsMapleLeaf
0dad3c9ecd style fixes and improvements 2023-08-16 20:33:53 -05:00
itsMapleLeaf
eea1a7ee9d solve some weird inclusion errors 2023-08-16 20:02:58 -05:00
itsMapleLeaf
e9e5a1617b tooling overhaul 2023-08-16 19:32:28 -05:00
itsMapleLeaf
7ac1a9cdce improve typecheck setup 2023-03-12 19:44:59 -05:00
itsMapleLeaf
33841a0c84 remove some extra scripts 2023-03-12 19:31:33 -05:00
120 changed files with 7233 additions and 6846 deletions

View File

@@ -1,11 +1,11 @@
{ {
"$schema": "https://unpkg.com/@changesets/config@2.1.0/schema.json", "$schema": "https://unpkg.com/@changesets/config@2.1.0/schema.json",
"changelog": "@changesets/cli/changelog", "changelog": "@changesets/cli/changelog",
"commit": false, "commit": false,
"fixed": [], "fixed": [],
"linked": [], "linked": [],
"access": "public", "access": "public",
"baseBranch": "main", "baseBranch": "main",
"updateInternalDependencies": "patch", "updateInternalDependencies": "patch",
"ignore": [] "ignore": []
} }

View File

@@ -1,38 +0,0 @@
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"),
extraFileExtensions: [".astro"],
},
overrides: [
{
files: ["packages/website/cypress/**"],
parserOptions: {
project: require.resolve("./packages/website/cypress/tsconfig.json"),
},
},
{
files: ["*.astro"],
parser: "astro-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
},
rules: {
"react/no-unknown-property": "off",
"react/jsx-key": "off",
"react/jsx-no-undef": "off",
},
},
],
}

View File

@@ -1,53 +0,0 @@
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 }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
run-commands:
strategy:
fail-fast: false
matrix:
command:
# if these run in the same process, it dies,
# so we test them separate
- name: test reacord
run: pnpm -C packages/reacord test
# - name: test website
# run: 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:
- name: checkout
uses: actions/checkout@v3
- name: setup pnpm
uses: pnpm/action-setup@v2
with:
version: 7.13.4
- name: setup node
uses: actions/setup-node@v3
with:
node-version: 16
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: ${{ matrix.command.run }}

View File

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

44
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
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()

5
.gitignore vendored
View File

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

View File

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

View File

@@ -1,14 +0,0 @@
const base = require("@itsmapleleaf/configs/prettier")
module.exports = {
...base,
plugins: [require.resolve("prettier-plugin-astro")],
overrides: [
{
files: "*.astro",
options: {
parser: "astro",
},
},
],
}

View File

@@ -1,5 +1,5 @@
<center> <center>
<img src="./packages/website/app/assets/banner.png" alt="Reacord: Create interactive Discord messages using React"> <img src="packages/website/src/assets/banner.png" alt="Reacord: Create interactive Discord messages using React">
</center> </center>
## Installation ∙ [![npm](https://img.shields.io/npm/v/reacord?color=blue&style=flat-square)](https://www.npmjs.com/package/reacord) ## Installation ∙ [![npm](https://img.shields.io/npm/v/reacord?color=blue&style=flat-square)](https://www.npmjs.com/package/reacord)

View File

@@ -2,25 +2,43 @@
"name": "reacord-monorepo", "name": "reacord-monorepo",
"private": true, "private": true,
"scripts": { "scripts": {
"lint": "eslint --ext js,ts,tsx .", "lint": "run-s --continue-on-error lint:*",
"lint-fix": "pnpm lint -- --fix", "lint:eslint": "eslint . --fix --cache --cache-file=node_modules/.cache/.eslintcache --report-unused-disable-directives",
"format": "prettier --write .", "lint:prettier": "prettier . \"**/*.astro\" --write --cache --list-different",
"lint:types": "tsc -b & pnpm -r --parallel run typecheck",
"astro-sync": "pnpm --filter website exec astro sync",
"test": "vitest",
"build": "pnpm -r run build", "build": "pnpm -r run build",
"build:website": "pnpm --filter website... run build",
"start": "pnpm -C packages/website run start", "start": "pnpm -C packages/website run start",
"start:website": "pnpm -C packages/website run start",
"release": "pnpm -r run build && changeset publish" "release": "pnpm -r run build && changeset publish"
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.25.0", "@changesets/cli": "^2.26.2",
"@itsmapleleaf/configs": "^1.1.7", "@itsmapleleaf/configs": "github:itsMapleLeaf/configs",
"@rushstack/eslint-patch": "^1.2.0", "eslint": "^8.51.0",
"@types/eslint": "^8.4.6", "npm-run-all": "^4.1.5",
"astro-eslint-parser": "^0.12.0", "prettier": "^3.0.3",
"eslint": "^8.36.0", "react": "^18.2.0",
"prettier": "^2.7.1", "tailwindcss": "^3.3.3",
"prettier-plugin-astro": "^0.8.0", "typescript": "^5.2.2",
"typescript": "^4.8.4" "vitest": "^0.34.6"
}, },
"resolutions": { "prettier": "@itsmapleleaf/configs/prettier",
"esbuild": "latest" "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"
}
} }
} }

View File

@@ -1,42 +1,42 @@
import { camelCaseDeep, snakeCaseDeep } from "./convert-object-property-case"
import type { import type {
CamelCasedPropertiesDeep, CamelCasedPropertiesDeep,
SnakeCasedPropertiesDeep, SnakeCasedPropertiesDeep,
} from "type-fest" } from "type-fest"
import { expect, test } from "vitest" import { expect, test } from "vitest"
import { camelCaseDeep, snakeCaseDeep } from "./convert-object-property-case"
test("camelCaseDeep", () => { test("camelCaseDeep", () => {
const input = { const input = {
some_prop: { some_prop: {
some_deep_prop: "some_deep_value", some_deep_prop: "some_deep_value",
}, },
someOtherProp: "someOtherValue", someOtherProp: "someOtherValue",
} }
const expected: CamelCasedPropertiesDeep<typeof input> = { const expected: CamelCasedPropertiesDeep<typeof input> = {
someProp: { someProp: {
someDeepProp: "some_deep_value", someDeepProp: "some_deep_value",
}, },
someOtherProp: "someOtherValue", someOtherProp: "someOtherValue",
} }
expect(camelCaseDeep(input)).toEqual(expected) expect(camelCaseDeep(input)).toEqual(expected)
}) })
test("snakeCaseDeep", () => { test("snakeCaseDeep", () => {
const input = { const input = {
someProp: { someProp: {
someDeepProp: "someDeepValue", someDeepProp: "someDeepValue",
}, },
some_other_prop: "someOtherValue", some_other_prop: "someOtherValue",
} }
const expected: SnakeCasedPropertiesDeep<typeof input> = { const expected: SnakeCasedPropertiesDeep<typeof input> = {
some_prop: { some_prop: {
some_deep_prop: "someDeepValue", some_deep_prop: "someDeepValue",
}, },
some_other_prop: "someOtherValue", some_other_prop: "someOtherValue",
} }
expect(snakeCaseDeep(input)).toEqual(expected) expect(snakeCaseDeep(input)).toEqual(expected)
}) })

View File

@@ -1,34 +1,35 @@
import { camelCase, isObject, snakeCase } from "lodash-es" import { camelCase, isObject, snakeCase } from "lodash-es"
import type { import type {
CamelCasedPropertiesDeep, CamelCasedPropertiesDeep,
SnakeCasedPropertiesDeep, SnakeCasedPropertiesDeep,
UnknownRecord,
} from "type-fest" } from "type-fest"
function convertKeyCaseDeep<Input, Output>( function convertKeyCaseDeep<Input, Output>(
input: Input, input: Input,
convertKey: (key: string) => string, convertKey: (key: string) => string,
): Output { ): Output {
if (!isObject(input)) { if (!isObject(input)) {
return input as unknown as Output return input as unknown as Output
} }
if (Array.isArray(input)) { if (Array.isArray(input)) {
return input.map((item) => return input.map((item) =>
convertKeyCaseDeep(item, convertKey), convertKeyCaseDeep(item, convertKey),
) as unknown as Output ) as unknown as Output
} }
const output: any = {} const output = {} as UnknownRecord
for (const [key, value] of Object.entries(input)) { for (const [key, value] of Object.entries(input)) {
output[convertKey(key)] = convertKeyCaseDeep(value, convertKey) output[convertKey(key)] = convertKeyCaseDeep(value, convertKey)
} }
return output return output as Output
} }
export function camelCaseDeep<T>(input: T): CamelCasedPropertiesDeep<T> { export function camelCaseDeep<T>(input: T): CamelCasedPropertiesDeep<T> {
return convertKeyCaseDeep(input, camelCase) return convertKeyCaseDeep(input, camelCase)
} }
export function snakeCaseDeep<T>(input: T): SnakeCasedPropertiesDeep<T> { export function snakeCaseDeep<T>(input: T): SnakeCasedPropertiesDeep<T> {
return convertKeyCaseDeep(input, snakeCase) return convertKeyCaseDeep(input, snakeCase)
} }

View File

@@ -1,5 +1,5 @@
import { raise } from "./raise.js" import { raise } from "./raise.js"
export function getEnvironmentValue(name: string) { export function getEnvironmentValue(name: string) {
return process.env[name] ?? raise(`Missing environment variable: ${name}`) return process.env[name] ?? raise(`Missing environment variable: ${name}`)
} }

View File

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

View File

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

7
packages/helpers/json.ts Normal file
View File

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

View File

@@ -1,3 +1,3 @@
export function last<T>(array: T[]): T | undefined { export function last<T>(array: T[]): T | undefined {
return array[array.length - 1] return array[array.length - 1]
} }

View File

@@ -1,11 +1,11 @@
import { inspect } from "node:util" import { inspect } from "node:util"
export function logPretty(value: unknown) { export function logPretty(value: unknown) {
console.info( console.info(
inspect(value, { inspect(value, {
// depth: Number.POSITIVE_INFINITY, // depth: Number.POSITIVE_INFINITY,
depth: 10, depth: 10,
colors: true, colors: true,
}), }),
) )
} }

View File

@@ -0,0 +1,7 @@
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

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

View File

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

View File

@@ -1,11 +1,15 @@
{ {
"name": "@reacord/helpers", "name": "@reacord/helpers",
"type": "module",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"scripts": {
"typecheck": "tsc -b"
},
"dependencies": { "dependencies": {
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.9",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"type-fest": "^2.17.0", "type-fest": "^4.4.0",
"vitest": "^0.18.1" "vitest": "^0.34.6"
} }
} }

View File

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

View File

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

View File

@@ -1,42 +1,46 @@
import { isObject } from "./is-object" import { isObject } from "./is-object"
export function pruneNullishValues<T>(input: T): PruneNullishValues<T> { export function pruneNullishValues<T>(input: T): PruneNullishValues<T> {
if (Array.isArray(input)) { if (!isObject(input)) {
return input.filter(Boolean).map((item) => pruneNullishValues(item)) as any return input as PruneNullishValues<T>
} }
if (!isObject(input)) { if (Array.isArray(input)) {
return input as any return input
} .filter(Boolean)
.map(
(item) => pruneNullishValues(item) as unknown,
) as PruneNullishValues<T>
}
const result: any = {} const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(input as any)) { for (const [key, value] of Object.entries(input)) {
if (value != undefined) { if (value != undefined) {
result[key] = pruneNullishValues(value) result[key] = pruneNullishValues(value)
} }
} }
return result return result as PruneNullishValues<T>
} }
export type PruneNullishValues<Input> = Input extends object export type PruneNullishValues<Input> = Input extends object
? OptionalKeys< ? OptionalKeys<
{ [Key in keyof Input]: NonNullable<PruneNullishValues<Input[Key]>> }, { [Key in keyof Input]: NonNullable<PruneNullishValues<Input[Key]>> },
KeysWithNullishValues<Input> KeysWithNullishValues<Input>
> >
: Input : Input
type OptionalKeys<Input, Keys extends keyof Input> = Omit<Input, Keys> & { type OptionalKeys<Input, Keys extends keyof Input> = Omit<Input, Keys> & {
[Key in Keys]?: Input[Key] [Key in Keys]?: Input[Key]
} }
type KeysWithNullishValues<Input> = NonNullable< type KeysWithNullishValues<Input> = NonNullable<
Values<{ Values<{
[Key in keyof Input]: null extends Input[Key] [Key in keyof Input]: null extends Input[Key]
? Key ? Key
: undefined extends Input[Key] : undefined extends Input[Key]
? Key ? Key
: never : never
}> }>
> >
type Values<Input> = Input[keyof Input] type Values<Input> = Input[keyof Input]

View File

@@ -1,5 +1,5 @@
import { toError } from "./to-error.js" import { toError } from "./to-error.js"
export function raise(error: unknown): never { export function raise(error: unknown): never {
throw toError(error) throw toError(error)
} }

View File

@@ -1,10 +1,10 @@
import { setTimeout } from "node:timers/promises"
import { toError } from "./to-error.js" import { toError } from "./to-error.js"
import { setTimeout } from "node:timers/promises"
export async function rejectAfter( export async function rejectAfter(
timeMs: number, timeMs: number,
error: unknown = `rejected after ${timeMs}ms`, error: unknown = `rejected after ${timeMs}ms`,
): Promise<never> { ): Promise<never> {
await setTimeout(timeMs) await setTimeout(timeMs)
throw toError(error) throw toError(error)
} }

View File

@@ -4,18 +4,18 @@ const maxTime = 500
const waitPeriod = 50 const waitPeriod = 50
export async function retryWithTimeout<T>( export async function retryWithTimeout<T>(
callback: () => Promise<T> | T, callback: () => Promise<T> | T,
): Promise<T> { ): Promise<T> {
const startTime = Date.now() const startTime = Date.now()
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition
while (true) { while (true) {
try { try {
return await callback() return await callback()
} catch (error) { } catch (error) {
if (Date.now() - startTime > maxTime) { if (Date.now() - startTime > maxTime) {
throw error throw error
} }
await setTimeout(waitPeriod) await setTimeout(waitPeriod)
} }
} }
} }

View File

@@ -1,3 +1,3 @@
export function toError(value: unknown) { export function toError(value: unknown) {
return value instanceof Error ? value : new Error(String(value)) return value instanceof Error ? value : new Error(String(value))
} }

View File

@@ -1,4 +1,4 @@
/** A typesafe version of toUpperCase */ /** A typesafe version of toUpperCase */
export function toUpper<S extends string>(string: S) { export function toUpper<S extends string>(string: S) {
return string.toUpperCase() as Uppercase<S> return string.toUpperCase() as Uppercase<S>
} }

View File

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

View File

@@ -0,0 +1,4 @@
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,11 +1,21 @@
export type MaybePromise<T> = T | Promise<T> import { raise } from "./raise.ts"
export type MaybePromise<T> = T | PromiseLike<T>
export type ValueOf<Type> = Type extends ReadonlyArray<infer Value> export type ValueOf<Type> = Type extends ReadonlyArray<infer Value>
? Value ? Value
: Type[keyof Type] : Type[keyof Type]
export type UnknownRecord = Record<PropertyKey, unknown> export type LoosePick<Shape, Keys extends PropertyKey> = Simplify<{
[Key in Extract<Keys, keyof Shape>]: Shape[Key]
}>
export type LoosePick<Shape, Keys extends PropertyKey> = { export type LooseOmit<Shape, Keys extends PropertyKey> = Simplify<{
[Key in Keys]: Shape extends Record<Key, infer Value> ? Value : never [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")

View File

@@ -1,21 +1,23 @@
import { setTimeout } from "node:timers/promises" import { setTimeout } from "node:timers/promises"
import type { MaybePromise } from "./types.ts"
const maxTime = 1000 const maxTime = 1000
export async function waitFor<Result>( export async function waitFor<Result>(
predicate: () => Result, predicate: () => MaybePromise<Result>,
): Promise<Awaited<Result>> { ): Promise<Awaited<Result>> {
const startTime = Date.now() const startTime = Date.now()
let lastError: unknown let lastError: unknown
while (Date.now() - startTime < maxTime) { while (Date.now() - startTime < maxTime) {
try { try {
return await predicate() return await predicate()
} catch (error) { } catch (error) {
lastError = error lastError = error
await setTimeout(50) await setTimeout(50)
} }
} }
throw lastError ?? new Error("Timeout") // eslint-disable-next-line @typescript-eslint/no-throw-literal
throw lastError ?? new Error("Timeout")
} }

View File

@@ -1,24 +1,24 @@
import { inspect } from "node:util" import { inspect } from "node:util"
export function withLoggedMethodCalls<T extends object>(value: T) { export function withLoggedMethodCalls<T extends object>(value: T) {
return new Proxy(value as Record<string | symbol, unknown>, { return new Proxy(value as Record<string | symbol, unknown>, {
get(target, property) { get(target, property) {
const value = target[property] const value = target[property]
if (typeof value !== "function") { if (typeof value !== "function") {
return value return value
} }
return (...values: any[]) => { return (...values: unknown[]) => {
console.info( console.info(
`${String(property)}(${values `${String(property)}(${values
.map((value) => .map((value) =>
typeof value === "object" && value !== null typeof value === "object" && value !== null
? value.constructor.name ? value.constructor.name
: inspect(value, { colors: true }), : inspect(value, { colors: true }),
) )
.join(", ")})`, .join(", ")})`,
) )
return value.apply(target, values) return value.apply(target, values) as unknown
} }
}, },
}) as T }) as T
} }

View File

@@ -1,5 +1,67 @@
# reacord # reacord
## 0.6.0
### Minor Changes
- 11153df: breaking: more descriptive component event types
- fb0a997: add new descriptive adapter methods
The reacord instance names have been updated, and the old names are now deprecated.
- `send` -> `createChannelMessage`
- `reply` -> `createInteractionReply`
These new methods also accept discord JS options. Usage example:
```ts
// can accept either a channel object or a channel ID
reacord.createChannelMessage(channel)
reacord.createChannelMessage(channel, {
tts: true,
})
reacord.createChannelMessage(channel, {
reply: {
messageReference: "123456789012345678",
failIfNotExists: false,
},
})
reacord.createInteractionReply(interaction)
reacord.createInteractionReply(interaction, {
ephemeral: true,
})
```
These new methods replace the old ones, which are now deprecated.
## 0.5.5
### Patch Changes
- ced48a3: distribute d.ts files again instead of the source
distributing the source causes typecheck errors when the modules it imports from (in this case, `@reacord/helpers`) don't exist in the end users' projects, so we'll just distribute d.ts files instead like normal. failed experiment :(
## 0.5.4
### Patch Changes
- 41c87e3: fix type definitions
`"types"` wasn't updated, oops!
technically the typedefs were already correctly defined via `"exports"`, but this may not be picked up depending on the tsconfig, so I'll ensure both are used for compatibility purposes. but this might be worth a note in the docs; pretty much every modern TS Node project should be using a tsconfig that doesn't require setting `"types"`
## 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 ## 0.5.2
### Patch Changes ### Patch Changes

1
packages/reacord/env.d.ts vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,153 +1,152 @@
import { isInstanceOf } from "@reacord/helpers/is-instance-of" import { isInstanceOf } from "@reacord/helpers/is-instance-of"
import { randomUUID } from "node:crypto" import { randomUUID } from "node:crypto"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import React from "react"
import { ReacordElement } from "../../internal/element.js" import { ReacordElement } from "../../internal/element.js"
import type { ComponentInteraction } from "../../internal/interaction" import type { ComponentInteraction } from "../../internal/interaction"
import type { import type {
ActionRow, ActionRow,
ActionRowItem, ActionRowItem,
MessageOptions, MessageOptions,
} from "../../internal/message" } from "../../internal/message"
import { Node } from "../../internal/node.js" import { Node } from "../../internal/node.js"
import type { ComponentEvent } from "../component-event" import type { ComponentEvent } from "../component-event"
import { OptionNode } from "./option-node" import { OptionNode } from "./option-node"
import { omit } from "@reacord/helpers/omit.js"
/** /** @category Select */
* @category Select export interface SelectProps {
*/ children?: ReactNode
export type SelectProps = { /** Sets the currently selected value */
children?: ReactNode value?: string
/** Sets the currently selected value */
value?: string
/** Sets the currently selected values, for use with `multiple` */ /** Sets the currently selected values, for use with `multiple` */
values?: string[] values?: string[]
/** The text shown when no value is selected */ /** The text shown when no value is selected */
placeholder?: string placeholder?: string
/** Set to true to allow multiple selected values */ /** Set to true to allow multiple selected values */
multiple?: boolean multiple?: boolean
/** /**
* With `multiple`, the minimum number of values that can be selected. * With `multiple`, the minimum number of values that can be selected. When
* When `multiple` is false or not defined, this is always 1. * `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 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. * This does not limit the number of values that can be displayed by you.
*/ */
minValues?: number minValues?: number
/** /**
* With `multiple`, the maximum number of values that can be selected. * With `multiple`, the maximum number of values that can be selected. When
* When `multiple` is false or not defined, this is always 1. * `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 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. * This does not limit the number of values that can be displayed by you.
*/ */
maxValues?: number maxValues?: number
/** When true, the select will be slightly faded, and cannot be interacted with. */ /**
disabled?: boolean * When true, the select will be slightly faded, and cannot be interacted
* with.
*/
disabled?: boolean
/** /**
* Called when the user inputs a selection. * Called when the user inputs a selection. Receives the entire select change
* Receives the entire select change event, * event, which can be used to create new replies, etc.
* which can be used to create new replies, etc. */
*/ onChange?: (event: SelectChangeEvent) => void
onChange?: (event: SelectChangeEvent) => void
/** /**
* Convenience shorthand for `onChange`, which receives the first selected value. * Convenience shorthand for `onChange`, which receives the first selected
*/ * value.
onChangeValue?: (value: string, event: SelectChangeEvent) => void */
onChangeValue?: (value: string, event: SelectChangeEvent) => void
/** /** Convenience shorthand for `onChange`, which receives all selected values. */
* Convenience shorthand for `onChange`, which receives all selected values. onChangeMultiple?: (values: string[], event: SelectChangeEvent) => void
*/
onChangeMultiple?: (values: string[], event: SelectChangeEvent) => void
} }
/** /** @category Select */
* @category Select
*/
export type SelectChangeEvent = ComponentEvent & { export type SelectChangeEvent = ComponentEvent & {
values: string[] values: string[]
} }
/** /**
* See [the select menu guide](/guides/select-menu) for a usage example. * See [the select menu guide](/guides/select-menu) for a usage example.
*
* @category Select * @category Select
*/ */
export function Select(props: SelectProps) { export function Select(props: SelectProps) {
return ( return (
<ReacordElement props={props} createNode={() => new SelectNode(props)}> <ReacordElement props={props} createNode={() => new SelectNode(props)}>
{props.children} {props.children}
</ReacordElement> </ReacordElement>
) )
} }
class SelectNode extends Node<SelectProps> { class SelectNode extends Node<SelectProps> {
readonly customId = randomUUID() readonly customId = randomUUID()
override modifyMessageOptions(message: MessageOptions): void { override modifyMessageOptions(message: MessageOptions): void {
const actionRow: ActionRow = [] const actionRow: ActionRow = []
message.actionRows.push(actionRow) message.actionRows.push(actionRow)
const options = [...this.children] const options = [...this.children]
.filter(isInstanceOf(OptionNode)) .filter(isInstanceOf(OptionNode))
.map((node) => node.options) .map((node) => node.options)
const { const {
multiple, multiple,
value, value,
values, values,
minValues = 0, minValues = 0,
maxValues = 25, maxValues = 25,
children, ...props
onChange, } = omit(this.props, [
onChangeValue, "children",
onChangeMultiple, "onChange",
...props "onChangeValue",
} = this.props "onChangeMultiple",
])
const item: ActionRowItem = { const item: ActionRowItem = {
...props, ...props,
type: "select", type: "select",
customId: this.customId, customId: this.customId,
options, options,
values: [], values: [],
} }
if (multiple) { if (multiple) {
item.minValues = minValues item.minValues = minValues
item.maxValues = maxValues item.maxValues = maxValues
if (values) item.values = values if (values) item.values = values
} }
if (!multiple && value != undefined) { if (!multiple && value != undefined) {
item.values = [value] item.values = [value]
} }
actionRow.push(item) actionRow.push(item)
} }
override handleComponentInteraction( override handleComponentInteraction(
interaction: ComponentInteraction, interaction: ComponentInteraction,
): boolean { ): boolean {
const isSelectInteraction = const isSelectInteraction =
interaction.type === "select" && interaction.type === "select" &&
interaction.customId === this.customId && interaction.customId === this.customId &&
!this.props.disabled !this.props.disabled
if (!isSelectInteraction) return false if (!isSelectInteraction) return false
this.props.onChange?.(interaction.event) this.props.onChange?.(interaction.event)
this.props.onChangeMultiple?.(interaction.event.values, interaction.event) this.props.onChangeMultiple?.(interaction.event.values, interaction.event)
if (interaction.event.values[0]) { if (interaction.event.values[0]) {
this.props.onChangeValue?.(interaction.event.values[0], interaction.event) this.props.onChangeValue?.(interaction.event.values[0], interaction.event)
} }
return true return true
} }
} }

View File

@@ -1,6 +1,6 @@
import { raise } from "@reacord/helpers/raise" import type { ReacordInstance } from "./instance.js"
import { raise } from "@reacord/helpers/raise.js"
import * as React from "react" import * as React from "react"
import type { ReacordInstance } from "./instance"
const Context = React.createContext<ReacordInstance | undefined>(undefined) const Context = React.createContext<ReacordInstance | undefined>(undefined)
@@ -13,8 +13,8 @@ export const InstanceProvider = Context.Provider
* @see https://reacord.mapleleaf.dev/guides/use-instance * @see https://reacord.mapleleaf.dev/guides/use-instance
*/ */
export function useInstance(): ReacordInstance { export function useInstance(): ReacordInstance {
return ( return (
React.useContext(Context) ?? React.useContext(Context) ??
raise("Could not find instance, was this component rendered via Reacord?") raise("Could not find instance, was this component rendered via Reacord?")
) )
} }

View File

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

View File

@@ -1,4 +1,4 @@
/* eslint-disable class-methods-use-this */ import { safeJsonStringify } from "@reacord/helpers/json"
import { pick } from "@reacord/helpers/pick" import { pick } from "@reacord/helpers/pick"
import { pruneNullishValues } from "@reacord/helpers/prune-nullish-values" import { pruneNullishValues } from "@reacord/helpers/prune-nullish-values"
import { raise } from "@reacord/helpers/raise" import { raise } from "@reacord/helpers/raise"
@@ -7,18 +7,19 @@ import type { ReactNode } from "react"
import type { Except } from "type-fest" import type { Except } from "type-fest"
import type { ComponentInteraction } from "../internal/interaction" import type { ComponentInteraction } from "../internal/interaction"
import type { import type {
Message, Message,
MessageButtonOptions, MessageButtonOptions,
MessageOptions, MessageOptions,
} from "../internal/message" } from "../internal/message"
import { ChannelMessageRenderer } from "../internal/renderers/channel-message-renderer" import { ChannelMessageRenderer } from "../internal/renderers/channel-message-renderer"
import { InteractionReplyRenderer } from "../internal/renderers/interaction-reply-renderer" import { InteractionReplyRenderer } from "../internal/renderers/interaction-reply-renderer"
import type { import type {
ChannelInfo, ComponentEventChannel,
GuildInfo, ComponentEventGuild,
GuildMemberInfo, ComponentEventGuildMember,
MessageInfo, ComponentEventMessage,
UserInfo, ComponentEventReplyOptions,
ComponentEventUser,
} from "./component-event" } from "./component-event"
import type { ReacordInstance } from "./instance" import type { ReacordInstance } from "./instance"
import type { ReacordConfig } from "./reacord" import type { ReacordConfig } from "./reacord"
@@ -26,364 +27,404 @@ import { Reacord } from "./reacord"
/** /**
* The Reacord adapter for Discord.js. * The Reacord adapter for Discord.js.
*
* @category Core * @category Core
*/ */
export class ReacordDiscordJs extends Reacord { export class ReacordDiscordJs extends Reacord {
constructor(private client: Discord.Client, config: ReacordConfig = {}) { constructor(
super(config) private client: Discord.Client,
config: ReacordConfig = {},
) {
super(config)
client.on("interactionCreate", (interaction) => { client.on("interactionCreate", (interaction) => {
if (interaction.isButton() || interaction.isSelectMenu()) { if (interaction.isButton() || interaction.isStringSelectMenu()) {
this.handleComponentInteraction( this.handleComponentInteraction(
this.createReacordComponentInteraction(interaction), this.createReacordComponentInteraction(interaction),
) )
} }
}) })
} }
/** /**
* Sends a message to a channel. * Sends a message to a channel.
* @see https://reacord.mapleleaf.dev/guides/sending-messages *
*/ * @param target Discord channel object.
override send( * @param [options] Options for the channel message
channelId: string, * @see https://reacord.mapleleaf.dev/guides/sending-messages
initialContent?: React.ReactNode, * @see {@link Discord.MessageCreateOptions}
): ReacordInstance { */
return this.createInstance( public createChannelMessage(
this.createChannelRenderer(channelId), target: Discord.ChannelResolvable,
initialContent, options: Discord.MessageCreateOptions = {},
) ): ReacordInstance {
} return this.createInstance(
this.createChannelMessageRenderer(target, options),
)
}
/** /**
* Sends a message as a reply to a command interaction. * Replies to a command interaction by sending a message.
* @see https://reacord.mapleleaf.dev/guides/sending-messages *
*/ * @param interaction Discord command interaction object.
override reply( * @param [options] Custom options for the interaction reply method.
interaction: Discord.CommandInteraction, * @see https://reacord.mapleleaf.dev/guides/sending-messages
initialContent?: React.ReactNode, * @see {@link Discord.InteractionReplyOptions}
): ReacordInstance { */
return this.createInstance( public createInteractionReply(
this.createInteractionReplyRenderer(interaction), interaction: Discord.CommandInteraction,
initialContent, options: Discord.InteractionReplyOptions = {},
) ): ReacordInstance {
} return this.createInstance(
this.createInteractionReplyRenderer(interaction, options),
)
}
/** /**
* Sends an ephemeral message as a reply to a command interaction. * Sends a message to a channel.
* @see https://reacord.mapleleaf.dev/guides/sending-messages *
*/ * @deprecated Use reacord.createChannelMessage() instead.
override ephemeralReply( * @see https://reacord.mapleleaf.dev/guides/sending-messages
interaction: Discord.CommandInteraction, */
initialContent?: React.ReactNode, public send(
): ReacordInstance { channel: Discord.ChannelResolvable,
return this.createInstance( initialContent?: React.ReactNode,
this.createEphemeralInteractionReplyRenderer(interaction), ): ReacordInstance {
initialContent, return this.createInstance(
) this.createChannelMessageRenderer(channel, {}),
} initialContent,
)
}
private createChannelRenderer(channelId: string) { /**
return new ChannelMessageRenderer({ * Sends a message as a reply to a command interaction.
send: async (options) => { *
const channel = * @deprecated Use reacord.createInteractionReply() instead.
this.client.channels.cache.get(channelId) ?? * @see https://reacord.mapleleaf.dev/guides/sending-messages
(await this.client.channels.fetch(channelId)) ?? */
raise(`Channel ${channelId} not found`) public reply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createInteractionReplyRenderer(interaction, {}),
initialContent,
)
}
if (!channel.isTextBased()) { /**
raise(`Channel ${channelId} is not a text channel`) * Sends an ephemeral message as a reply to a command interaction.
} *
* @deprecated Use reacord.createInteractionReply(interaction, { ephemeral:
* true })
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
public ephemeralReply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createInteractionReplyRenderer(interaction, {
ephemeral: true,
}),
initialContent,
)
}
const message = await channel.send(getDiscordMessageOptions(options)) private createChannelMessageRenderer(
return createReacordMessage(message) channelResolvable: Discord.ChannelResolvable,
}, messageCreateOptions?: Discord.MessageCreateOptions,
}) ) {
} return new ChannelMessageRenderer({
send: async (messageOptions) => {
let channel = this.client.channels.resolve(channelResolvable)
if (!channel && typeof channelResolvable === "string") {
channel = await this.client.channels.fetch(channelResolvable)
}
private createInteractionReplyRenderer( if (!channel) {
interaction: const id =
| Discord.CommandInteraction typeof channelResolvable === "string"
| Discord.MessageComponentInteraction, ? channelResolvable
) { : channelResolvable.id
return new InteractionReplyRenderer({ raise(`Channel ${id} not found`)
type: "command", }
id: interaction.id,
reply: async (options) => {
const message = await interaction.reply({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
followUp: async (options) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
})
}
private createEphemeralInteractionReplyRenderer( if (!channel.isTextBased()) {
interaction: raise(`Channel ${channel.id} must be a text channel`)
| 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( const message = await channel.send({
interaction: Discord.MessageComponentInteraction, ...getDiscordMessageOptions(messageOptions),
): ComponentInteraction { ...messageCreateOptions,
// todo please dear god clean this up })
const channel: ChannelInfo = interaction.channel return createReacordMessage(message)
? { },
...pruneNullishValues( })
pick(interaction.channel, [ }
"topic",
"nsfw",
"lastMessageId",
"ownerId",
"parentId",
"rateLimitPerUser",
]),
),
id: interaction.channelId,
}
: raise("Non-channel interactions are not supported")
const message: MessageInfo = private createInteractionReplyRenderer(
interaction.message instanceof Discord.Message interaction:
? { | Discord.CommandInteraction
...pick(interaction.message, [ | Discord.MessageComponentInteraction,
"id", interactionReplyOptions: Discord.InteractionReplyOptions,
"channelId", ) {
"authorId", return new InteractionReplyRenderer({
"content", interactionId: interaction.id,
"tts", reply: async (messageOptions) => {
"mentionEveryone", const message = await interaction.reply({
]), ...getDiscordMessageOptions(messageOptions),
timestamp: new Date( ...interactionReplyOptions,
interaction.message.createdTimestamp, fetchReply: true,
).toISOString(), })
editedTimestamp: interaction.message.editedTimestamp return createReacordMessage(message)
? new Date(interaction.message.editedTimestamp).toISOString() },
: undefined, followUp: async (messageOptions) => {
mentions: interaction.message.mentions.users.map((u) => u.id), const message = await interaction.followUp({
} ...getDiscordMessageOptions(messageOptions),
: raise("Message not found") ...interactionReplyOptions,
fetchReply: true,
})
return createReacordMessage(message)
},
})
}
const member: GuildMemberInfo | undefined = private createReacordComponentInteraction(
interaction.member instanceof Discord.GuildMember interaction: Discord.MessageComponentInteraction,
? { ): ComponentInteraction {
...pruneNullishValues( // todo please dear god clean this up
pick(interaction.member, [ const channel: ComponentEventChannel = interaction.channel
"id", ? {
"nick", ...pruneNullishValues(
"displayName", pick(interaction.channel, [
"avatarUrl", "topic",
"displayAvatarUrl", "nsfw",
"color", "lastMessageId",
"pending", "ownerId",
]), "parentId",
), "rateLimitPerUser",
displayName: interaction.member.displayName, ]),
roles: interaction.member.roles.cache.map((role) => role.id), ),
joinedAt: interaction.member.joinedAt?.toISOString(), id: interaction.channelId,
premiumSince: interaction.member.premiumSince?.toISOString(), }
communicationDisabledUntil: : raise("Non-channel interactions are not supported")
interaction.member.communicationDisabledUntil?.toISOString(),
}
: undefined
const guild: GuildInfo | undefined = interaction.guild const message: ComponentEventMessage =
? { interaction.message instanceof Discord.Message
...pruneNullishValues(pick(interaction.guild, ["id", "name"])), ? {
member: member ?? raise("unexpected: member is undefined"), ...pick(interaction.message, [
} "id",
: undefined "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 user: UserInfo = { const member: ComponentEventGuildMember | undefined =
...pruneNullishValues( interaction.member instanceof Discord.GuildMember
pick(interaction.user, ["id", "username", "discriminator", "tag"]), ? {
), ...pruneNullishValues(
avatarUrl: interaction.user.avatarURL()!, pick(interaction.member, [
accentColor: interaction.user.accentColor ?? undefined, "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 baseProps: Except<ComponentInteraction, "type"> = { const guild: ComponentEventGuild | undefined = interaction.guild
id: interaction.id, ? {
customId: interaction.customId, ...pruneNullishValues(pick(interaction.guild, ["id", "name"])),
update: async (options: MessageOptions) => { member: member ?? raise("unexpected: member is undefined"),
await interaction.update(getDiscordMessageOptions(options)) }
}, : undefined
deferUpdate: async () => {
if (interaction.replied || interaction.deferred) return
await interaction.deferUpdate()
},
reply: async (options) => {
const message = await interaction.reply({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
followUp: async (options) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
event: {
channel,
message,
user,
guild,
reply: (content?: ReactNode) => const user: ComponentEventUser = {
this.createInstance( ...pruneNullishValues(
this.createInteractionReplyRenderer(interaction), pick(interaction.user, ["id", "username", "discriminator", "tag"]),
content, ),
), avatarUrl: interaction.user.avatarURL(),
accentColor: interaction.user.accentColor ?? undefined,
}
ephemeralReply: (content: ReactNode) => const baseProps: Except<ComponentInteraction, "type"> = {
this.createInstance( id: interaction.id,
this.createEphemeralInteractionReplyRenderer(interaction), customId: interaction.customId,
content, 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,
if (interaction.isButton()) { reply: (content?: ReactNode, options?: ComponentEventReplyOptions) =>
return { this.createInstance(
...baseProps, this.createInteractionReplyRenderer(interaction, options ?? {}),
type: "button", content,
} ),
}
if (interaction.isSelectMenu()) { /** @deprecated Use event.reply(content, { ephemeral: true }) */
return { ephemeralReply: (content: ReactNode) =>
...baseProps, this.createInstance(
type: "select", this.createInteractionReplyRenderer(interaction, {
event: { ephemeral: true,
...baseProps.event, }),
values: interaction.values, content,
}, ),
} },
} }
raise(`Unsupported component interaction type: ${interaction.type}`) 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 { function createReacordMessage(message: Discord.Message): Message {
return { return {
edit: async (options) => { edit: async (options) => {
await message.edit(getDiscordMessageOptions(options)) await message.edit(getDiscordMessageOptions(options))
}, },
delete: async () => { delete: async () => {
await message.delete() 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"]) { function convertButtonStyleToEnum(style: MessageButtonOptions["style"]) {
const styleMap = { const styleMap = {
primary: Discord.ButtonStyle.Primary, primary: Discord.ButtonStyle.Primary,
secondary: Discord.ButtonStyle.Secondary, secondary: Discord.ButtonStyle.Secondary,
success: Discord.ButtonStyle.Success, success: Discord.ButtonStyle.Success,
danger: Discord.ButtonStyle.Danger, danger: Discord.ButtonStyle.Danger,
} as const } as const
return styleMap[style ?? "secondary"] return styleMap[style ?? "secondary"]
} }
// TODO: this could be a part of the core library, // TODO: this could be a part of the core library,
// and also handle some edge cases, e.g. empty messages // and also handle some edge cases, e.g. empty messages
function getDiscordMessageOptions(reacordOptions: MessageOptions) { function getDiscordMessageOptions(reacordOptions: MessageOptions) {
const options = { const options = {
// eslint-disable-next-line unicorn/no-null content: reacordOptions.content || undefined,
content: reacordOptions.content || null, embeds: reacordOptions.embeds,
embeds: reacordOptions.embeds, components: reacordOptions.actionRows.map((row) => ({
components: reacordOptions.actionRows.map((row) => ({ type: Discord.ComponentType.ActionRow,
type: Discord.ComponentType.ActionRow, components: row.map(
components: row.map( (component): Discord.MessageActionRowComponentData => {
(component): Discord.MessageActionRowComponentData => { if (component.type === "button") {
if (component.type === "button") { return {
return { type: Discord.ComponentType.Button,
type: Discord.ComponentType.Button, customId: component.customId,
customId: component.customId, label: component.label ?? "",
label: component.label ?? "", style: convertButtonStyleToEnum(component.style),
style: convertButtonStyleToEnum(component.style), disabled: component.disabled,
disabled: component.disabled, emoji: component.emoji,
emoji: component.emoji, }
} }
}
if (component.type === "link") { if (component.type === "link") {
return { return {
type: Discord.ComponentType.Button, type: Discord.ComponentType.Button,
url: component.url, url: component.url,
label: component.label ?? "", label: component.label ?? "",
style: Discord.ButtonStyle.Link, style: Discord.ButtonStyle.Link,
disabled: component.disabled, disabled: component.disabled,
emoji: component.emoji, emoji: component.emoji,
} }
} }
if (component.type === "select") { // future proofing
return { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
...component, if (component.type === "select") {
type: Discord.ComponentType.SelectMenu, return {
options: component.options.map((option) => ({ ...component,
...option, type: Discord.ComponentType.SelectMenu,
default: component.values?.includes(option.value), options: component.options.map((option) => ({
})), ...option,
} default: component.values?.includes(option.value),
} })),
}
}
raise(`Unsupported component type: ${(component as any).type}`) component satisfies never
}, throw new Error(
), `Invalid component type ${safeJsonStringify(component)}}`,
})), )
} },
),
})),
}
if (!options.content && !options.embeds?.length) { if (!options.content && !options.embeds.length) {
options.content = "_ _" options.content = "_ _"
} }
return options return options
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,28 +8,28 @@ export type ComponentInteraction = ButtonInteraction | SelectInteraction
export type CommandInteraction = BaseInteraction<"command"> export type CommandInteraction = BaseInteraction<"command">
export type ButtonInteraction = BaseComponentInteraction< export type ButtonInteraction = BaseComponentInteraction<
"button", "button",
ButtonClickEvent ButtonClickEvent
> >
export type SelectInteraction = BaseComponentInteraction< export type SelectInteraction = BaseComponentInteraction<
"select", "select",
SelectChangeEvent SelectChangeEvent
> >
export type BaseInteraction<Type extends string> = { export interface BaseInteraction<Type extends string> {
type: Type type: Type
id: string id: string
reply(messageOptions: MessageOptions): Promise<Message> reply(messageOptions: MessageOptions): Promise<Message>
followUp(messageOptions: MessageOptions): Promise<Message> followUp(messageOptions: MessageOptions): Promise<Message>
} }
export type BaseComponentInteraction< export type BaseComponentInteraction<
Type extends string, Type extends string,
Event extends ComponentEvent, Event extends ComponentEvent,
> = BaseInteraction<Type> & { > = BaseInteraction<Type> & {
event: Event event: Event
customId: string customId: string
update(options: MessageOptions): Promise<void> update(options: MessageOptions): Promise<void>
deferUpdate(): Promise<void> deferUpdate(): Promise<void>
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import type { Interaction } from "../interaction"
import type { Message, MessageOptions } from "../message" import type { Message, MessageOptions } from "../message"
import { Renderer } from "./renderer" import { Renderer } from "./renderer"
@@ -6,17 +5,23 @@ import { Renderer } from "./renderer"
// so we know whether to call reply() or followUp() // so we know whether to call reply() or followUp()
const repliedInteractionIds = new Set<string>() const repliedInteractionIds = new Set<string>()
export class InteractionReplyRenderer extends Renderer { export type InteractionReplyRendererImplementation = {
constructor(private interaction: Interaction) { interactionId: string
super() reply: (options: MessageOptions) => Promise<Message>
} followUp: (options: MessageOptions) => Promise<Message>
}
protected createMessage(options: MessageOptions): Promise<Message> {
if (repliedInteractionIds.has(this.interaction.id)) { export class InteractionReplyRenderer extends Renderer {
return this.interaction.followUp(options) constructor(private implementation: InteractionReplyRendererImplementation) {
} super()
}
repliedInteractionIds.add(this.interaction.id)
return this.interaction.reply(options) protected createMessage(options: MessageOptions): Promise<Message> {
} if (repliedInteractionIds.has(this.implementation.interactionId)) {
return this.implementation.followUp(options)
}
repliedInteractionIds.add(this.implementation.interactionId)
return this.implementation.reply(options)
}
} }

View File

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

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

View File

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

View File

@@ -2,8 +2,7 @@
"name": "reacord", "name": "reacord",
"type": "module", "type": "module",
"description": "Create interactive Discord messages using React.", "description": "Create interactive Discord messages using React.",
"version": "0.5.2", "version": "0.6.0",
"types": "./dist/main.d.ts",
"homepage": "https://reacord.mapleleaf.dev", "homepage": "https://reacord.mapleleaf.dev",
"repository": "https://github.com/itsMapleLeaf/reacord.git", "repository": "https://github.com/itsMapleLeaf/reacord.git",
"changelog": "https://github.com/itsMapleLeaf/reacord/releases", "changelog": "https://github.com/itsMapleLeaf/reacord/releases",
@@ -24,6 +23,7 @@
"README.md", "README.md",
"LICENSE" "LICENSE"
], ],
"types": "./dist/main.d.ts",
"exports": { "exports": {
".": { ".": {
"import": "./dist/main.js", "import": "./dist/main.js",
@@ -36,19 +36,19 @@
} }
}, },
"scripts": { "scripts": {
"build": "cp ../../README.md . && cp ../../LICENSE . && tsup library/main.ts --target node16 --format cjs,esm --dts --sourcemap", "build": "cpy ../../README.md ../../LICENSE . && tsup library/main.ts --target node18 --format cjs,esm --sourcemap --dts --dts-resolve",
"build-watch": "pnpm build -- --watch", "build-watch": "pnpm build -- --watch",
"test": "vitest --coverage --no-watch", "test": "vitest --coverage --no-watch",
"test-dev": "vitest", "test-dev": "vitest",
"test-manual": "nodemon --exec tsx --ext ts,tsx ./scripts/discordjs-manual-test.tsx", "test-manual": "nodemon --exec tsx --ext ts,tsx ./scripts/discordjs-manual-test.tsx",
"typecheck": "tsc --noEmit" "typecheck": "tsc -b"
}, },
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "^20.8.4",
"@types/react": "*", "@types/react": "^18.2.27",
"@types/react-reconciler": "^0.28.0", "@types/react-reconciler": "^0.28.5",
"react-reconciler": "^0.29.0", "react-reconciler": "^0.29.0",
"rxjs": "^7.5.6" "rxjs": "^7.8.1"
}, },
"peerDependencies": { "peerDependencies": {
"discord.js": "^14", "discord.js": "^14",
@@ -61,23 +61,19 @@
}, },
"devDependencies": { "devDependencies": {
"@reacord/helpers": "workspace:*", "@reacord/helpers": "workspace:*",
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.9",
"c8": "^7.12.0", "c8": "^8.0.1",
"discord.js": "^14.0.3", "cpy-cli": "^5.0.0",
"dotenv": "^16.0.1", "discord.js": "^14.13.0",
"dotenv": "^16.3.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"nodemon": "^2.0.19", "nodemon": "^3.0.1",
"prettier": "^2.7.1", "prettier": "^3.0.3",
"pretty-ms": "^8.0.0", "pretty-ms": "^8.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"tsup": "^6.1.3", "tsup": "^7.2.0",
"tsx": "^3.8.0", "tsx": "^3.13.0",
"type-fest": "^2.17.0", "type-fest": "^4.4.0"
"typescript": "^4.7.4",
"vitest": "^0.18.1"
},
"resolutions": {
"esbuild": "latest"
}, },
"release-it": { "release-it": {
"git": { "git": {

View File

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

View File

@@ -1,4 +1,3 @@
import React from "react"
import { test } from "vitest" import { test } from "vitest"
import { ActionRow, Button, Select } from "../library/main" import { ActionRow, Button, Select } from "../library/main"
import { ReacordTester } from "./test-adapter" import { ReacordTester } from "./test-adapter"
@@ -6,36 +5,36 @@ import { ReacordTester } from "./test-adapter"
const testing = new ReacordTester() const testing = new ReacordTester()
test("action row", async () => { test("action row", async () => {
await testing.assertRender( await testing.assertRender(
<> <>
<Button label="outside button" onClick={() => {}} /> <Button label="outside button" onClick={() => {}} />
<ActionRow> <ActionRow>
<Button label="button inside action row" onClick={() => {}} /> <Button label="button inside action row" onClick={() => {}} />
</ActionRow> </ActionRow>
<Select /> <Select />
<Button label="last row 1" onClick={() => {}} /> <Button label="last row 1" onClick={() => {}} />
<Button label="last row 2" onClick={() => {}} /> <Button label="last row 2" onClick={() => {}} />
</>, </>,
[ [
{ {
content: "", content: "",
embeds: [], embeds: [],
actionRows: [ actionRows: [
[{ type: "button", style: "secondary", label: "outside button" }], [{ type: "button", style: "secondary", label: "outside button" }],
[ [
{ {
type: "button", type: "button",
style: "secondary", style: "secondary",
label: "button inside action row", label: "button inside action row",
}, },
], ],
[{ type: "select", options: [], values: [] }], [{ type: "select", options: [], values: [] }],
[ [
{ type: "button", style: "secondary", label: "last row 1" }, { type: "button", style: "secondary", label: "last row 1" },
{ type: "button", style: "secondary", label: "last row 2" }, { type: "button", style: "secondary", label: "last row 2" },
], ],
], ],
}, },
], ],
) )
}) })

View File

@@ -3,10 +3,10 @@ import { createRequire } from "node:module"
import { beforeAll, expect, test } from "vitest" import { beforeAll, expect, test } from "vitest"
beforeAll(() => { beforeAll(() => {
spawnSync("pnpm", ["run", "build"]) spawnSync("pnpm", ["run", "build"])
}) })
test("can require commonjs", () => { test("can require commonjs", () => {
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
expect(() => require("../dist/main.cjs")).not.toThrow() expect(() => require("../dist/main.cjs") as unknown).not.toThrow()
}) })

View File

@@ -1,275 +1,274 @@
import React from "react"
import { test } from "vitest" import { test } from "vitest"
import { import {
Embed, Embed,
EmbedAuthor, EmbedAuthor,
EmbedField, EmbedField,
EmbedFooter, EmbedFooter,
EmbedImage, EmbedImage,
EmbedThumbnail, EmbedThumbnail,
EmbedTitle, EmbedTitle,
} from "../library/main" } from "../library/main"
import { ReacordTester } from "./test-adapter" import { ReacordTester } from "./test-adapter"
const testing = new ReacordTester() const testing = new ReacordTester()
test("kitchen sink", async () => { test("kitchen sink", async () => {
const now = new Date() const now = new Date()
await testing.assertRender( await testing.assertRender(
<> <>
<Embed color={0xfe_ee_ef}> <Embed color={0xfe_ee_ef}>
<EmbedAuthor name="author" iconUrl="https://example.com/author.png" /> <EmbedAuthor name="author" iconUrl="https://example.com/author.png" />
<EmbedTitle>title text</EmbedTitle> <EmbedTitle>title text</EmbedTitle>
description text description text
<EmbedThumbnail url="https://example.com/thumbnail.png" /> <EmbedThumbnail url="https://example.com/thumbnail.png" />
<EmbedImage url="https://example.com/image.png" /> <EmbedImage url="https://example.com/image.png" />
<EmbedField name="field name" value="field value" inline /> <EmbedField name="field name" value="field value" inline />
<EmbedField name="block field" value="block field value" /> <EmbedField name="block field" value="block field value" />
<EmbedFooter <EmbedFooter
text="footer text" text="footer text"
iconUrl="https://example.com/footer.png" iconUrl="https://example.com/footer.png"
timestamp={now} timestamp={now}
/> />
</Embed> </Embed>
</>, </>,
[ [
{ {
actionRows: [], actionRows: [],
content: "", content: "",
embeds: [ embeds: [
{ {
description: "description text", description: "description text",
author: { author: {
icon_url: "https://example.com/author.png", icon_url: "https://example.com/author.png",
name: "author", name: "author",
}, },
color: 0xfe_ee_ef, color: 0xfe_ee_ef,
fields: [ fields: [
{ {
inline: true, inline: true,
name: "field name", name: "field name",
value: "field value", value: "field value",
}, },
{ {
name: "block field", name: "block field",
value: "block field value", value: "block field value",
}, },
], ],
footer: { footer: {
icon_url: "https://example.com/footer.png", icon_url: "https://example.com/footer.png",
text: "footer text", text: "footer text",
}, },
image: { image: {
url: "https://example.com/image.png", url: "https://example.com/image.png",
}, },
thumbnail: { thumbnail: {
url: "https://example.com/thumbnail.png", url: "https://example.com/thumbnail.png",
}, },
timestamp: now.toISOString(), timestamp: now.toISOString(),
title: "title text", title: "title text",
}, },
], ],
}, },
], ],
) )
}) })
test("author variants", async () => { test("author variants", async () => {
await testing.assertRender( await testing.assertRender(
<> <>
<Embed> <Embed>
<EmbedAuthor iconUrl="https://example.com/author.png"> <EmbedAuthor iconUrl="https://example.com/author.png">
author name author name
</EmbedAuthor> </EmbedAuthor>
</Embed> </Embed>
<Embed> <Embed>
<EmbedAuthor iconUrl="https://example.com/author.png" /> <EmbedAuthor iconUrl="https://example.com/author.png" />
</Embed> </Embed>
</>, </>,
[ [
{ {
content: "", content: "",
actionRows: [], actionRows: [],
embeds: [ embeds: [
{ {
author: { author: {
icon_url: "https://example.com/author.png", icon_url: "https://example.com/author.png",
name: "author name", name: "author name",
}, },
}, },
{ {
author: { author: {
icon_url: "https://example.com/author.png", icon_url: "https://example.com/author.png",
name: "", name: "",
}, },
}, },
], ],
}, },
], ],
) )
}) })
test("field variants", async () => { test("field variants", async () => {
await testing.assertRender( await testing.assertRender(
<> <>
<Embed> <Embed>
<EmbedField name="field name" value="field value" /> <EmbedField name="field name" value="field value" />
<EmbedField name="field name" value="field value" inline /> <EmbedField name="field name" value="field value" inline />
<EmbedField name="field name" inline> <EmbedField name="field name" inline>
field value field value
</EmbedField> </EmbedField>
<EmbedField name="field name" /> <EmbedField name="field name" />
</Embed> </Embed>
</>, </>,
[ [
{ {
content: "", content: "",
actionRows: [], actionRows: [],
embeds: [ embeds: [
{ {
fields: [ fields: [
{ {
name: "field name", name: "field name",
value: "field value", value: "field value",
}, },
{ {
inline: true, inline: true,
name: "field name", name: "field name",
value: "field value", value: "field value",
}, },
{ {
inline: true, inline: true,
name: "field name", name: "field name",
value: "field value", value: "field value",
}, },
{ {
name: "field name", name: "field name",
value: "", value: "",
}, },
], ],
}, },
], ],
}, },
], ],
) )
}) })
test("footer variants", async () => { test("footer variants", async () => {
const now = new Date() const now = new Date()
await testing.assertRender( await testing.assertRender(
<> <>
<Embed> <Embed>
<EmbedFooter text="footer text" /> <EmbedFooter text="footer text" />
</Embed> </Embed>
<Embed> <Embed>
<EmbedFooter <EmbedFooter
text="footer text" text="footer text"
iconUrl="https://example.com/footer.png" iconUrl="https://example.com/footer.png"
/> />
</Embed> </Embed>
<Embed> <Embed>
<EmbedFooter timestamp={now}>footer text</EmbedFooter> <EmbedFooter timestamp={now}>footer text</EmbedFooter>
</Embed> </Embed>
<Embed> <Embed>
<EmbedFooter iconUrl="https://example.com/footer.png" timestamp={now} /> <EmbedFooter iconUrl="https://example.com/footer.png" timestamp={now} />
</Embed> </Embed>
</>, </>,
[ [
{ {
content: "", content: "",
actionRows: [], actionRows: [],
embeds: [ embeds: [
{ {
footer: { footer: {
text: "footer text", text: "footer text",
}, },
}, },
{ {
footer: { footer: {
icon_url: "https://example.com/footer.png", icon_url: "https://example.com/footer.png",
text: "footer text", text: "footer text",
}, },
}, },
{ {
footer: { footer: {
text: "footer text", text: "footer text",
}, },
timestamp: now.toISOString(), timestamp: now.toISOString(),
}, },
{ {
footer: { footer: {
icon_url: "https://example.com/footer.png", icon_url: "https://example.com/footer.png",
text: "", text: "",
}, },
timestamp: now.toISOString(), timestamp: now.toISOString(),
}, },
], ],
}, },
], ],
) )
}) })
test("embed props", async () => { test("embed props", async () => {
const now = new Date() const now = new Date()
await testing.assertRender( await testing.assertRender(
<Embed <Embed
title="title text" title="title text"
description="description text" description="description text"
url="https://example.com/" url="https://example.com/"
color={0xfe_ee_ef} color={0xfe_ee_ef}
timestamp={now} timestamp={now}
author={{ author={{
name: "author name", name: "author name",
url: "https://example.com/author", url: "https://example.com/author",
iconUrl: "https://example.com/author.png", iconUrl: "https://example.com/author.png",
}} }}
thumbnail={{ thumbnail={{
url: "https://example.com/thumbnail.png", url: "https://example.com/thumbnail.png",
}} }}
image={{ image={{
url: "https://example.com/image.png", url: "https://example.com/image.png",
}} }}
footer={{ footer={{
text: "footer text", text: "footer text",
iconUrl: "https://example.com/footer.png", iconUrl: "https://example.com/footer.png",
}} }}
fields={[ fields={[
{ name: "field name", value: "field value", inline: true }, { name: "field name", value: "field value", inline: true },
{ name: "block field", value: "block field value" }, { name: "block field", value: "block field value" },
]} ]}
/>, />,
[ [
{ {
content: "", content: "",
actionRows: [], actionRows: [],
embeds: [ embeds: [
{ {
title: "title text", title: "title text",
description: "description text", description: "description text",
url: "https://example.com/", url: "https://example.com/",
color: 0xfe_ee_ef, color: 0xfe_ee_ef,
timestamp: now.toISOString(), timestamp: now.toISOString(),
author: { author: {
name: "author name", name: "author name",
url: "https://example.com/author", url: "https://example.com/author",
icon_url: "https://example.com/author.png", icon_url: "https://example.com/author.png",
}, },
thumbnail: { url: "https://example.com/thumbnail.png" }, thumbnail: { url: "https://example.com/thumbnail.png" },
image: { url: "https://example.com/image.png" }, image: { url: "https://example.com/image.png" },
footer: { footer: {
text: "footer text", text: "footer text",
icon_url: "https://example.com/footer.png", icon_url: "https://example.com/footer.png",
}, },
fields: [ fields: [
{ name: "field name", value: "field value", inline: true }, { name: "field name", value: "field value", inline: true },
{ name: "block field", value: "block field value" }, { name: "block field", value: "block field value" },
], ],
}, },
], ],
}, },
], ],
) )
}) })

View File

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

View File

@@ -1,4 +1,3 @@
import React from "react"
import { test } from "vitest" import { test } from "vitest"
import { Link } from "../library/main" import { Link } from "../library/main"
import { ReacordTester } from "./test-adapter" import { ReacordTester } from "./test-adapter"
@@ -6,37 +5,37 @@ import { ReacordTester } from "./test-adapter"
const tester = new ReacordTester() const tester = new ReacordTester()
test("link", async () => { test("link", async () => {
await tester.assertRender( await tester.assertRender(
<> <>
<Link url="https://example.com/">link text</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/" />
<Link label="link text" url="https://example.com/" disabled /> <Link label="link text" url="https://example.com/" disabled />
</>, </>,
[ [
{ {
content: "", content: "",
embeds: [], embeds: [],
actionRows: [ actionRows: [
[ [
{ {
type: "link", type: "link",
url: "https://example.com/", url: "https://example.com/",
label: "link text", label: "link text",
}, },
{ {
type: "link", type: "link",
url: "https://example.com/", url: "https://example.com/",
label: "link text", label: "link text",
}, },
{ {
type: "link", type: "link",
url: "https://example.com/", url: "https://example.com/",
label: "link text", label: "link text",
disabled: true, disabled: true,
}, },
], ],
], ],
}, },
], ],
) )
}) })

View File

@@ -1,270 +1,270 @@
import * as React from "react"
import { test } from "vitest"
import { Button, Embed, EmbedField, EmbedTitle } from "../library/main" import { Button, Embed, EmbedField, EmbedTitle } from "../library/main"
import { ReacordTester } from "./test-adapter" import { ReacordTester } from "./test-adapter"
import * as React from "react"
import { test } from "vitest"
test("rendering behavior", async () => { test("rendering behavior", async () => {
const tester = new ReacordTester() const tester = new ReacordTester()
const reply = tester.reply() const reply = tester
reply.render(<KitchenSinkCounter onDeactivate={() => reply.deactivate()} />) .createInteractionReply()
.render(<KitchenSinkCounter onDeactivate={() => reply.deactivate()} />)
await tester.assertMessages([ await tester.assertMessages([
{ {
content: "count: 0", content: "count: 0",
embeds: [], embeds: [],
actionRows: [ actionRows: [
[ [
{ {
type: "button", type: "button",
style: "primary", style: "primary",
label: "clicc", label: "clicc",
}, },
{ {
type: "button", type: "button",
style: "secondary", style: "secondary",
label: "show embed", label: "show embed",
}, },
{ {
type: "button", type: "button",
style: "danger", style: "danger",
label: "deactivate", label: "deactivate",
}, },
], ],
], ],
}, },
]) ])
await tester.findButtonByLabel("show embed").click() await tester.findButtonByLabel("show embed").click()
await tester.assertMessages([ await tester.assertMessages([
{ {
content: "count: 0", content: "count: 0",
embeds: [{ title: "the counter" }], embeds: [{ title: "the counter" }],
actionRows: [ actionRows: [
[ [
{ {
type: "button", type: "button",
style: "secondary", style: "secondary",
label: "hide embed", label: "hide embed",
}, },
{ {
type: "button", type: "button",
style: "primary", style: "primary",
label: "clicc", label: "clicc",
}, },
{ {
type: "button", type: "button",
style: "danger", style: "danger",
label: "deactivate", label: "deactivate",
}, },
], ],
], ],
}, },
]) ])
await tester.findButtonByLabel("clicc").click() await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([ await tester.assertMessages([
{ {
content: "count: 1", content: "count: 1",
embeds: [ embeds: [
{ {
title: "the counter", title: "the counter",
fields: [{ name: "is it even?", value: "no" }], fields: [{ name: "is it even?", value: "no" }],
}, },
], ],
actionRows: [ actionRows: [
[ [
{ {
type: "button", type: "button",
style: "secondary", style: "secondary",
label: "hide embed", label: "hide embed",
}, },
{ {
type: "button", type: "button",
style: "primary", style: "primary",
label: "clicc", label: "clicc",
}, },
{ {
type: "button", type: "button",
style: "danger", style: "danger",
label: "deactivate", label: "deactivate",
}, },
], ],
], ],
}, },
]) ])
await tester.findButtonByLabel("clicc").click() await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([ await tester.assertMessages([
{ {
content: "count: 2", content: "count: 2",
embeds: [ embeds: [
{ {
title: "the counter", title: "the counter",
fields: [{ name: "is it even?", value: "yes" }], fields: [{ name: "is it even?", value: "yes" }],
}, },
], ],
actionRows: [ actionRows: [
[ [
{ {
type: "button", type: "button",
style: "secondary", style: "secondary",
label: "hide embed", label: "hide embed",
}, },
{ {
type: "button", type: "button",
style: "primary", style: "primary",
label: "clicc", label: "clicc",
}, },
{ {
type: "button", type: "button",
style: "danger", style: "danger",
label: "deactivate", label: "deactivate",
}, },
], ],
], ],
}, },
]) ])
await tester.findButtonByLabel("hide embed").click() await tester.findButtonByLabel("hide embed").click()
await tester.assertMessages([ await tester.assertMessages([
{ {
content: "count: 2", content: "count: 2",
embeds: [], embeds: [],
actionRows: [ actionRows: [
[ [
{ {
type: "button", type: "button",
style: "primary", style: "primary",
label: "clicc", label: "clicc",
}, },
{ {
type: "button", type: "button",
style: "secondary", style: "secondary",
label: "show embed", label: "show embed",
}, },
{ {
type: "button", type: "button",
style: "danger", style: "danger",
label: "deactivate", label: "deactivate",
}, },
], ],
], ],
}, },
]) ])
await tester.findButtonByLabel("clicc").click() await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([ await tester.assertMessages([
{ {
content: "count: 3", content: "count: 3",
embeds: [], embeds: [],
actionRows: [ actionRows: [
[ [
{ {
type: "button", type: "button",
style: "primary", style: "primary",
label: "clicc", label: "clicc",
}, },
{ {
type: "button", type: "button",
style: "secondary", style: "secondary",
label: "show embed", label: "show embed",
}, },
{ {
type: "button", type: "button",
style: "danger", style: "danger",
label: "deactivate", label: "deactivate",
}, },
], ],
], ],
}, },
]) ])
await tester.findButtonByLabel("deactivate").click() await tester.findButtonByLabel("deactivate").click()
await tester.assertMessages([ await tester.assertMessages([
{ {
content: "count: 3", content: "count: 3",
embeds: [], embeds: [],
actionRows: [ actionRows: [
[ [
{ {
type: "button", type: "button",
style: "primary", style: "primary",
label: "clicc", label: "clicc",
disabled: true, disabled: true,
}, },
{ {
type: "button", type: "button",
style: "secondary", style: "secondary",
label: "show embed", label: "show embed",
disabled: true, disabled: true,
}, },
{ {
type: "button", type: "button",
style: "danger", style: "danger",
label: "deactivate", label: "deactivate",
disabled: true, disabled: true,
}, },
], ],
], ],
}, },
]) ])
await tester.findButtonByLabel("clicc").click() await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([ await tester.assertMessages([
{ {
content: "count: 3", content: "count: 3",
embeds: [], embeds: [],
actionRows: [ actionRows: [
[ [
{ {
type: "button", type: "button",
style: "primary", style: "primary",
label: "clicc", label: "clicc",
disabled: true, disabled: true,
}, },
{ {
type: "button", type: "button",
style: "secondary", style: "secondary",
label: "show embed", label: "show embed",
disabled: true, disabled: true,
}, },
{ {
type: "button", type: "button",
style: "danger", style: "danger",
label: "deactivate", label: "deactivate",
disabled: true, disabled: true,
}, },
], ],
], ],
}, },
]) ])
}) })
test("delete", async () => { test("delete", async () => {
const tester = new ReacordTester() const tester = new ReacordTester()
const reply = tester.reply() const reply = tester.createInteractionReply().render(
reply.render( <>
<> some text
some text <Embed>some embed</Embed>
<Embed>some embed</Embed> <Button label="some button" onClick={() => {}} />
<Button label="some button" onClick={() => {}} /> </>,
</>, )
)
await tester.assertMessages([ await tester.assertMessages([
{ {
content: "some text", content: "some text",
embeds: [{ description: "some embed" }], embeds: [{ description: "some embed" }],
actionRows: [ actionRows: [
[{ type: "button", style: "secondary", label: "some button" }], [{ type: "button", style: "secondary", label: "some button" }],
], ],
}, },
]) ])
reply.destroy() reply.destroy()
await tester.assertMessages([]) await tester.assertMessages([])
}) })
// test multiple instances that can be updated independently, // test multiple instances that can be updated independently,
@@ -272,34 +272,34 @@ test("delete", async () => {
test.todo("multiple instances") test.todo("multiple instances")
function KitchenSinkCounter(props: { onDeactivate: () => void }) { function KitchenSinkCounter(props: { onDeactivate: () => void }) {
const [count, setCount] = React.useState(0) const [count, setCount] = React.useState(0)
const [embedVisible, setEmbedVisible] = React.useState(false) const [embedVisible, setEmbedVisible] = React.useState(false)
return ( return (
<> <>
count: {count} count: {count}
{embedVisible && ( {embedVisible && (
<Embed> <Embed>
<EmbedTitle>the counter</EmbedTitle> <EmbedTitle>the counter</EmbedTitle>
{count > 0 && ( {count > 0 && (
<EmbedField name="is it even?"> <EmbedField name="is it even?">
{count % 2 === 0 ? "yes" : "no"} {count % 2 === 0 ? "yes" : "no"}
</EmbedField> </EmbedField>
)} )}
</Embed> </Embed>
)} )}
{embedVisible && ( {embedVisible && (
<Button label="hide embed" onClick={() => setEmbedVisible(false)} /> <Button label="hide embed" onClick={() => setEmbedVisible(false)} />
)} )}
<Button <Button
style="primary" style="primary"
label="clicc" label="clicc"
onClick={() => setCount(count + 1)} onClick={() => setCount(count + 1)}
/> />
{!embedVisible && ( {!embedVisible && (
<Button label="show embed" onClick={() => setEmbedVisible(true)} /> <Button label="show embed" onClick={() => setEmbedVisible(true)} />
)} )}
<Button style="danger" label="deactivate" onClick={props.onDeactivate} /> <Button style="danger" label="deactivate" onClick={props.onDeactivate} />
</> </>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,7 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"include": ["**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.mjs"] "compilerOptions": {
"jsx": "react-jsx"
},
"exclude": ["node_modules", "dist"]
} }

View File

@@ -1,5 +1,37 @@
# website # website
## 0.4.7
### Patch Changes
- Updated dependencies [11153df]
- Updated dependencies [fb0a997]
- reacord@0.6.0
## 0.4.6
### Patch Changes
- Updated dependencies [ced48a3]
- reacord@0.5.5
## 0.4.5
### Patch Changes
- Updated dependencies [41c87e3]
- reacord@0.5.4
## 0.4.4
### Patch Changes
- Updated dependencies [104b175]
- Updated dependencies [156cf90]
- Updated dependencies [0bab505]
- Updated dependencies [d76f316]
- reacord@0.5.3
## 0.4.3 ## 0.4.3
### Patch Changes ### Patch Changes

View File

@@ -1,3 +1,5 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import prefetch from "@astrojs/prefetch" import prefetch from "@astrojs/prefetch"
import react from "@astrojs/react" import react from "@astrojs/react"
import tailwind from "@astrojs/tailwind" import tailwind from "@astrojs/tailwind"
@@ -5,13 +7,14 @@ import { defineConfig } from "astro/config"
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
integrations: [ integrations: [
tailwind({ tailwind({
config: { applyBaseStyles: false,
applyBaseStyles: false, }),
}, react(),
}), prefetch(),
react(), ],
prefetch(), markdown: {
], shikiConfig: {},
},
}) })

View File

@@ -1,42 +1,41 @@
{ {
"type": "module", "type": "module",
"name": "website", "name": "website",
"version": "0.4.3", "version": "0.4.7",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"scripts": { "scripts": {
"dev": "run-p --race --print-label dev:*", "dev": "run-p --race --print-label dev:*",
"dev:typedoc": "typedoc --watch", "dev:typedoc": "typedoc --watch",
"dev:astro": "astro dev", "dev:astro": "astro dev",
"test": "node ./scripts/test.js",
"test-dev": "run-p --race --print-label dev:* test-dev:*",
"test-dev:cypress": "wait-on http-get://localhost:3000 && cypress open",
"start": "astro preview", "start": "astro preview",
"build": "typedoc && astro build", "build": "typedoc && astro build",
"typecheck": "tsc --noEmit && tsc --project cypress/tsconfig.json --noEmit" "typecheck": "astro check && tsc -b"
}, },
"dependencies": { "dependencies": {
"@astrojs/prefetch": "^0.2.0", "@astrojs/prefetch": "^0.3.0",
"@astrojs/react": "^2.1.0", "@astrojs/react": "^2.3.2",
"@fontsource/jetbrains-mono": "^4.5.12", "@fontsource/jetbrains-mono": "^4.5.12",
"@fontsource/rubik": "^4.5.14", "@fontsource/rubik": "^4.5.14",
"@heroicons/react": "^2.0.16", "@heroicons/react": "^2.0.18",
"@tailwindcss/typography": "^0.5.9", "@reacord/helpers": "workspace:^",
"astro": "^2.1.2", "@tailwindcss/typography": "^0.5.10",
"clsx": "^1.2.1", "astro": "^2.10.15",
"clsx": "^2.0.0",
"reacord": "workspace:*", "reacord": "workspace:*",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0",
"tailwind-merge": "^1.14.0"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/tailwind": "^3.1.0", "@astrojs/tailwind": "^4.0.0",
"@types/node": "*", "@total-typescript/ts-reset": "^0.5.1",
"@types/react": "^18.0.28", "@types/node": "^20.8.4",
"@types/react-dom": "^18.0.11", "@types/react": "^18.2.27",
"@types/react-dom": "^18.2.12",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.3.3",
"typedoc": "^0.23.26", "typedoc": "^0.25.2",
"typescript": "^4.9.5",
"wait-on": "^7.0.1" "wait-on": "^7.0.1"
} }
} }

View File

@@ -1,14 +1,22 @@
--- ---
import { HeartIcon } from "@heroicons/react/20/solid" import { HeartIcon } from "@heroicons/react/20/solid"
import { twMerge } from "tailwind-merge"
import ExternalLink from "./external-link.astro" import ExternalLink from "./external-link.astro"
export interface Props {
class?: string
}
--- ---
<footer class="container text-xs opacity-75"> <footer class={twMerge("text-xs opacity-75", Astro.props.class)}>
<address class="not-italic"> <address class="not-italic">
&copy; {new Date().getFullYear()} itsMapleLeaf &copy; {new Date().getFullYear()}
</address> <ExternalLink class="link" href="https://github.com/itsMapleLeaf">
<p> itsMapleLeaf
Coded with <HeartIcon className="inline w-4 align-sub" /> using{" "} </ExternalLink>
<ExternalLink class="link" href="https://astro.build">Astro</ExternalLink> </address>
</p> <p>
Coded with <HeartIcon className="inline w-4 align-sub" /> using{" "}
<ExternalLink class="link" href="https://astro.build">Astro</ExternalLink>
</p>
</footer> </footer>

File diff suppressed because one or more lines are too long

View File

@@ -3,5 +3,5 @@ export type Props = astroHTML.JSX.AnchorHTMLAttributes
--- ---
<a rel="noopener noreferrer" target="_blank" {...Astro.props}> <a rel="noopener noreferrer" target="_blank" {...Astro.props}>
<slot /> <slot />
</a> </a>

View File

@@ -7,32 +7,32 @@ const guides = await getCollection("guides")
--- ---
<Layout> <Layout>
<div class="isolate"> <div class="isolate">
<header <header
class="bg-slate-700/30 shadow sticky top-0 backdrop-blur-sm transition z-10 flex" class="sticky top-0 z-10 flex bg-slate-700/30 shadow backdrop-blur-sm transition"
> >
<div class="container"> <div class="container">
<MainNavigation /> <MainNavigation />
</div> </div>
</header> </header>
<main class="container mt-8 flex items-start gap-4"> <main class="container mt-8 flex items-start gap-4">
<nav class="w-48 sticky top-24 hidden md:block"> <nav class="sticky top-24 hidden w-48 md:block">
<h2 class="text-2xl">Guides</h2> <h2 class="text-2xl">Guides</h2>
<ul class="mt-3 flex flex-col gap-2 items-start"> <ul class="mt-3 flex flex-col items-start gap-2">
{ {
guides.map((guide) => ( guides.map((guide) => (
<li> <li>
<a class="link" href={`/guides/${guide.slug}`}> <a class="link" href={`/guides/${guide.slug}`}>
{guide.data.title} {guide.data.title}
</a> </a>
</li> </li>
)) ))
} }
</ul> </ul>
</nav> </nav>
<section class="prose prose-invert pb-8 flex-1 min-w-0"> <section class="prose prose-invert min-w-0 flex-1 pb-8">
<slot /> <slot />
</section> </section>
</main> </main>
</div> </div>
</Layout> </Layout>

View File

@@ -3,193 +3,199 @@ import { useEffect, useRef, useState } from "react"
import blobComfyUrl from "~/assets/blob-comfy.png" import blobComfyUrl from "~/assets/blob-comfy.png"
import cursorIbeamUrl from "~/assets/cursor-ibeam.png" import cursorIbeamUrl from "~/assets/cursor-ibeam.png"
import cursorUrl from "~/assets/cursor.png" import cursorUrl from "~/assets/cursor.png"
import { raise } from "@reacord/helpers/raise.ts"
const defaultState = { const defaultState = {
chatInputText: "", chatInputText: "",
chatInputCursorVisible: true, chatInputCursorVisible: true,
messageVisible: false, messageVisible: false,
count: 0, count: 0,
cursorLeft: "25%", cursorLeft: "25%",
cursorBottom: "-15px", cursorBottom: "-15px",
} }
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const animationFrame = () => const animationFrame = () =>
new Promise((resolve) => requestAnimationFrame(resolve)) new Promise((resolve) => requestAnimationFrame(resolve))
export function LandingAnimation() { export function LandingAnimation() {
const [state, setState] = useState(defaultState) const [state, setState] = useState(defaultState)
const chatInputRef = useRef<HTMLDivElement>(null) const chatInputRef = useRef<HTMLDivElement>(null)
const addRef = useRef<HTMLDivElement>(null) const addRef = useRef<HTMLDivElement>(null)
const deleteRef = useRef<HTMLDivElement>(null) const deleteRef = useRef<HTMLDivElement>(null)
const cursorRef = useRef<HTMLImageElement>(null) const cursorRef = useRef<HTMLImageElement>(null)
useEffect(() => { useEffect(() => {
const animateClick = (element: HTMLElement) => const animateClick = (element: HTMLElement) =>
element.animate( element.animate(
[{ transform: `translateY(2px)` }, { transform: `translateY(0px)` }], [{ transform: `translateY(2px)` }, { transform: `translateY(0px)` }],
300, 300,
) )
let running = true let running = true
void (async () => { void (async () => {
while (running) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
setState(defaultState) while (running) {
await delay(700) setState(defaultState)
await delay(700)
for (const letter of "/counter") { for (const letter of "/counter") {
setState((state) => ({ setState((state) => ({
...state, ...state,
chatInputText: state.chatInputText + letter, chatInputText: state.chatInputText + letter,
})) }))
await delay(100) await delay(100)
} }
await delay(1000) await delay(1000)
setState((state) => ({ setState((state) => ({
...state, ...state,
messageVisible: true, messageVisible: true,
chatInputText: "", chatInputText: "",
})) }))
await delay(1000) await delay(1000)
setState((state) => ({ setState((state) => ({
...state, ...state,
cursorLeft: "70px", cursorLeft: "70px",
cursorBottom: "40px", cursorBottom: "40px",
})) }))
await delay(1500) await delay(1500)
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
setState((state) => ({ setState((state) => ({
...state, ...state,
count: state.count + 1, count: state.count + 1,
chatInputCursorVisible: false, chatInputCursorVisible: false,
})) }))
animateClick(addRef.current!) animateClick(addRef.current ?? raise("addRef is null"))
await delay(700) await delay(700)
} }
await delay(500) await delay(500)
setState((state) => ({ setState((state) => ({
...state, ...state,
cursorLeft: "140px", cursorLeft: "140px",
})) }))
await delay(1000) await delay(1000)
animateClick(deleteRef.current!) animateClick(deleteRef.current ?? raise("deleteRef is null"))
setState((state) => ({ ...state, messageVisible: false })) setState((state) => ({ ...state, messageVisible: false }))
await delay(1000) await delay(1000)
setState(() => ({ setState(() => ({
...defaultState, ...defaultState,
chatInputCursorVisible: false, chatInputCursorVisible: false,
})) }))
await delay(500) await delay(500)
} }
})() })()
return () => { return () => {
running = false running = false
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
let running = true let running = true
void (async () => { void (async () => {
while (running) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
// check if the cursor is in the input while (running) {
const cursorRect = cursorRef.current!.getBoundingClientRect() const cursor = cursorRef.current ?? raise("cursorRef is null")
const chatInputRect = chatInputRef.current!.getBoundingClientRect() const chatInput = chatInputRef.current ?? raise("chatInputRef is null")
const isOverInput = // check if the cursor is in the input
cursorRef.current && const cursorRect = cursor.getBoundingClientRect()
chatInputRef.current && const chatInputRect = chatInput.getBoundingClientRect()
cursorRect.top + cursorRect.height / 2 > chatInputRect.top
cursorRef.current!.src = isOverInput ? cursorIbeamUrl : cursorUrl const isOverInput =
cursorRef.current &&
chatInputRef.current &&
cursorRect.top + cursorRect.height / 2 > chatInputRect.top
await animationFrame() cursor.src = isOverInput ? cursorIbeamUrl : cursorUrl
}
})()
return () => { await animationFrame()
running = false }
} })()
})
return ( return () => {
<div running = false
className="grid gap-2 relative pointer-events-none select-none" }
role="presentation" })
>
<div
className={clsx(
"bg-slate-800 p-4 rounded-lg shadow transition",
state.messageVisible ? "opacity-100" : "opacity-0 -translate-y-2",
)}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 p-2 rounded-full bg-no-repeat bg-contain bg-black/25">
<img
src={blobComfyUrl}
alt=""
className="object-contain scale-90 w-full h-full"
/>
</div>
<div>
<p className="font-bold">comfybot</p>
<p>this button was clicked {state.count} times</p>
<div className="mt-2 flex flex-row gap-3">
<div
ref={addRef}
className="bg-emerald-700 text-white py-1.5 px-3 text-sm rounded"
>
+1
</div>
<div
ref={deleteRef}
className="bg-red-700 text-white py-1.5 px-3 text-sm rounded"
>
🗑 delete
</div>
</div>
</div>
</div>
</div>
<div
className="bg-slate-700 pb-2 pt-1.5 px-4 rounded-lg shadow"
ref={chatInputRef}
>
<span
className={clsx(
"text-sm after:content-[attr(data-after)] after:relative after:-top-px after:-left-[2px]",
state.chatInputCursorVisible
? "after:opacity-100"
: "after:opacity-0",
)}
data-after="|"
>
{state.chatInputText || (
<span className="opacity-50 block absolute translate-y-1">
Message #showing-off-reacord
</span>
)}
</span>
</div>
<img return (
src={cursorUrl} <div
alt="" className="animate-fade-in pointer-events-none relative grid select-none gap-2"
className="transition-all duration-500 absolute scale-75 bg-transparent" role="presentation"
style={{ left: state.cursorLeft, bottom: state.cursorBottom }} >
ref={cursorRef} <div
/> className={clsx(
</div> "rounded-lg bg-slate-800 p-4 shadow transition",
) state.messageVisible ? "opacity-100" : "-translate-y-2 opacity-0",
)}
>
<div className="flex items-start gap-4">
<div className="h-12 w-12 rounded-full bg-black/25 bg-contain bg-no-repeat p-2">
<img
src={blobComfyUrl}
alt=""
className="h-full w-full scale-90 object-contain"
/>
</div>
<div>
<p className="font-bold">comfybot</p>
<p>this button was clicked {state.count} times</p>
<div className="mt-2 flex flex-row gap-3">
<div
ref={addRef}
className="rounded bg-emerald-700 px-3 py-1.5 text-sm text-white"
>
+1
</div>
<div
ref={deleteRef}
className="rounded bg-red-700 px-3 py-1.5 text-sm text-white"
>
🗑 delete
</div>
</div>
</div>
</div>
</div>
<div
className="rounded-lg bg-slate-700 px-4 pb-2 pt-1.5 shadow"
ref={chatInputRef}
>
<span
className={clsx(
"text-sm after:relative after:-left-[2px] after:-top-px after:content-[attr(data-after)]",
state.chatInputCursorVisible
? "after:opacity-100"
: "after:opacity-0",
)}
data-after="|"
>
{state.chatInputText || (
<span className="absolute block translate-y-1 opacity-50">
Message #showing-off-reacord
</span>
)}
</span>
</div>
<img
src={cursorUrl}
alt=""
className="absolute scale-75 bg-transparent transition-all duration-500"
style={{ left: state.cursorLeft, bottom: state.cursorBottom }}
ref={cursorRef}
/>
</div>
)
} }

View File

@@ -4,41 +4,40 @@ import "@fontsource/rubik/variable.css"
import packageJson from "reacord/package.json" import packageJson from "reacord/package.json"
import bannerUrl from "~/assets/banner.png" import bannerUrl from "~/assets/banner.png"
import faviconUrl from "~/assets/favicon.png" import faviconUrl from "~/assets/favicon.png"
import "~/styles/prism-theme.css"
import "~/styles/tailwind.css" import "~/styles/tailwind.css"
--- ---
<!DOCTYPE html> <!doctype html>
<html lang="en" class="bg-slate-900 text-slate-100"> <html lang="en" class="bg-slate-900 text-slate-100">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={packageJson.description} /> <meta name="description" content={packageJson.description} />
<meta name="theme-color" content="#21754b" /> <meta name="theme-color" content="#21754b" />
<meta property="og:url" content="https://reacord.mapleleaf.dev/" /> <meta property="og:url" content="https://reacord.mapleleaf.dev/" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:title" content="Reacord" /> <meta property="og:title" content="Reacord" />
<meta <meta
property="og:description" property="og:description"
content="Create interactive Discord messages using React" content="Create interactive Discord messages using React"
/> />
<meta property="og:image" content={bannerUrl} /> <meta property="og:image" content={bannerUrl} />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:domain" content="reacord.mapleleaf.dev" /> <meta name="twitter:domain" content="reacord.mapleleaf.dev" />
<meta name="twitter:url" content="https://reacord.mapleleaf.dev/" /> <meta name="twitter:url" content="https://reacord.mapleleaf.dev/" />
<meta name="twitter:title" content="Reacord" /> <meta name="twitter:title" content="Reacord" />
<meta <meta
name="twitter:description" name="twitter:description"
content="Create interactive Discord messages using React" content="Create interactive Discord messages using React"
/> />
<meta name="twitter:image" content={bannerUrl} /> <meta name="twitter:image" content={bannerUrl} />
<title>Reacord</title> <title>Reacord</title>
<link rel="icon" href={faviconUrl} /> <link rel="icon" href={faviconUrl} />
</head> </head>
<body> <body>
<slot /> <slot />
</body> </body>
</html> </html>

View File

@@ -1,8 +1,8 @@
--- ---
import { import {
ArrowTopRightOnSquareIcon, ArrowTopRightOnSquareIcon,
CodeBracketIcon, CodeBracketIcon,
DocumentTextIcon, DocumentTextIcon,
} from "@heroicons/react/20/solid" } from "@heroicons/react/20/solid"
import { Bars3Icon } from "@heroicons/react/24/outline" import { Bars3Icon } from "@heroicons/react/24/outline"
import { getCollection } from "astro:content" import { getCollection } from "astro:content"
@@ -12,69 +12,70 @@ import MenuItem from "./menu-item.astro"
import Menu from "./menu.astro" import Menu from "./menu.astro"
const links = [ const links = [
{ {
href: "/guides/getting-started", href: "/guides/getting-started",
label: "Guides", label: "Guides",
icon: DocumentTextIcon, icon: DocumentTextIcon,
component: "a", component: "a",
prefetch: true, prefetch: true,
}, },
{ {
href: "/api/", href: "/api/",
label: "API Reference", label: "API Reference",
icon: CodeBracketIcon, icon: CodeBracketIcon,
component: "a", component: "a",
}, },
{ {
href: "https://github.com/itsMapleLeaf/reacord", href: "https://github.com/itsMapleLeaf/reacord",
label: "GitHub", label: "GitHub",
icon: ArrowTopRightOnSquareIcon, icon: ArrowTopRightOnSquareIcon,
component: ExternalLink, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
}, component: ExternalLink,
},
] ]
const guides = await getCollection("guides") const guides = await getCollection("guides")
--- ---
<nav class="flex justify-between items-center h-16"> <nav class="flex h-16 items-center justify-between">
<a href="/"> <a href="/">
<AppLogo class="w-32" /> <AppLogo class="w-32" />
<span class="sr-only">Home</span> <span class="sr-only">Home</span>
</a> </a>
<div class="hidden md:flex gap-4"> <div class="hidden gap-4 md:flex">
{ {
links.map((link) => ( links.map((link) => (
<link.component <link.component
href={link.href} href={link.href}
class="link inline-flex gap-1 items-center" class="link inline-flex items-center gap-1"
rel={link.prefetch ? "prefetch" : undefined} rel={link.prefetch ? "prefetch" : undefined}
> >
<link.icon className="inline-icon" /> <link.icon className="inline-icon" />
{link.label} {link.label}
</link.component> </link.component>
)) ))
} }
</div> </div>
<Menu> <Menu>
<Fragment slot="button"> <Fragment slot="button">
<Bars3Icon className="w-6" /> <Bars3Icon className="w-6" />
<span class="sr-only">Menu</span> <span class="sr-only">Menu</span>
</Fragment> </Fragment>
{ {
links.map((link) => ( links.map((link) => (
<link.component href={link.href}> <link.component href={link.href}>
<MenuItem icon={link.icon} label={link.label} /> <MenuItem icon={link.icon} label={link.label} />
</link.component> </link.component>
)) ))
} }
<hr class="border-black/25" /> <hr class="border-black/25" />
{ {
guides.map((guide) => ( guides.map((guide) => (
<a href={`/guides/${guide.slug}`} rel="prefetch"> <a href={`/guides/${guide.slug}`} rel="prefetch">
<MenuItem icon={DocumentTextIcon} label={guide.data.title} /> <MenuItem icon={DocumentTextIcon} label={guide.data.title} />
</a> </a>
)) ))
} }
</Menu> </Menu>
</nav> </nav>

View File

@@ -1,13 +1,13 @@
--- ---
export type Props = { export interface Props {
icon: (props: { class?: string; className?: string }) => any icon: (props: { class?: string; className?: string }) => unknown
label: string label: string
} }
--- ---
<div <div
class="px-3 py-2 transition text-left font-medium block w-full opacity-50 inline-flex gap-1 items-center hover:opacity-100 hover:text-emerald-500" class="flex w-full items-center gap-1 px-3 py-2 text-left font-medium opacity-50 transition hover:text-emerald-500 hover:opacity-100"
> >
<Astro.props.icon class="inline-icon" className="inline-icon" /> <Astro.props.icon class="inline-icon" className="inline-icon" />
<span class="flex-1">{Astro.props.label}</span> <span class="flex-1">{Astro.props.label}</span>
</div> </div>

View File

@@ -1,30 +1,30 @@
<details class="md:hidden relative" data-menu> <details class="relative md:hidden" data-menu>
<summary <summary
class="list-none p-2 -m-2 cursor-pointer hover:text-emerald-500 transition" class="-m-2 cursor-pointer list-none p-2 transition hover:text-emerald-500"
> >
<slot name="button" /> <slot name="button" />
</summary> </summary>
<div <div
class="w-48 max-h-[calc(100vh-5rem)] bg-slate-800 shadow rounded-lg overflow-x-hidden overflow-y-auto top-[calc(100%+8px)] right-0 absolute z-10" class="absolute right-0 top-[calc(100%+8px)] z-10 max-h-[calc(100vh-5rem)] w-48 overflow-y-auto overflow-x-hidden rounded-lg bg-slate-800 shadow"
> >
<slot /> <slot />
</div> </div>
</details> </details>
<script> <script>
for (const menu of document.querySelectorAll<HTMLDetailsElement>( for (const menu of document.querySelectorAll<HTMLDetailsElement>(
"[data-menu]", "[data-menu]",
)) { )) {
window.addEventListener("click", (event) => { window.addEventListener("click", (event) => {
if (!menu.contains(event.target as Node)) { if (!menu.contains(event.target as Node)) {
menu.open = false menu.open = false
} }
}) })
menu.addEventListener("keydown", (event) => { menu.addEventListener("keydown", (event) => {
if (event.key === "Escape") { if (event.key === "Escape") {
menu.open = false menu.open = false
menu.querySelector("summary")!.focus() menu.querySelector("summary")!.focus()
} }
}) })
} }
</script> </script>

View File

@@ -1,6 +1,6 @@
--- ---
export type Props = astroHTML.JSX.AnchorHTMLAttributes & { export type Props = astroHTML.JSX.AnchorHTMLAttributes & {
href: string href: string
} }
const removeTrailingSlash = (str: string) => str.replace(/\/$/, "") const removeTrailingSlash = (str: string) => str.replace(/\/$/, "")
@@ -8,10 +8,10 @@ const removeTrailingSlash = (str: string) => str.replace(/\/$/, "")
const linkUrl = new URL(Astro.props.href, Astro.url) const linkUrl = new URL(Astro.props.href, Astro.url)
const isActive = const isActive =
removeTrailingSlash(Astro.url.pathname) === removeTrailingSlash(Astro.url.pathname) ===
removeTrailingSlash(linkUrl.pathname) removeTrailingSlash(linkUrl.pathname)
--- ---
<a {...Astro.props} data-active={isActive || undefined}> <a {...Astro.props} data-active={isActive || undefined}>
<slot /> <slot />
</a> </a>

View File

@@ -1,10 +1,10 @@
import { defineCollection, z } from "astro:content" import { defineCollection, z } from "astro:content"
export const collections = { export const collections = {
guides: defineCollection({ guides: defineCollection({
schema: z.object({ schema: z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
}), }),
}), }),
} }

View File

@@ -6,7 +6,7 @@ slug: getting-started
# Getting Started # Getting Started
These guides assume some familiarity with JavaScript, [React](https://reactjs.org), [Discord.js](https://discord.js.org) and the [Discord API](https://discord.dev). Keep these pages as reference if you need it. These guides assume some familiarity with [JavaScript](https://developer.mozilla.org/en-US/docs/Web/javascript), [React](https://reactjs.org), [Discord.js](https://discord.js.org) and the [Discord API](https://discord.dev). Keep these pages as reference if you need it.
## Setup from template ## Setup from template
@@ -38,7 +38,7 @@ const client = new Client()
const reacord = new ReacordDiscordJs(client) const reacord = new ReacordDiscordJs(client)
client.on("ready", () => { client.on("ready", () => {
console.log("Ready!") console.log("Ready!")
}) })
await client.login(process.env.BOT_TOKEN) await client.login(process.env.BOT_TOKEN)
@@ -47,6 +47,13 @@ await client.login(process.env.BOT_TOKEN)
To use JSX in your code, run it with [tsx](https://npm.im/tsx): To use JSX in your code, run it with [tsx](https://npm.im/tsx):
```bash ```bash
npm install tsx npm install -D tsx
tsx main.tsx npx tsx main.tsx
```
For production, I recommend compiling it with [tsup](https://npm.im/tsup):
```bash
npm install -D tsup
npx tsup src/main.tsx --target node20
``` ```

View File

@@ -9,34 +9,34 @@ slug: sending-messages
You can send messages via Reacord to a channel like so. You can send messages via Reacord to a channel like so.
```jsx ```jsx
const channelId = "abc123deadbeef"
client.on("ready", () => { client.on("ready", () => {
reacord.send(channelId, "Hello, world!") const channel = await client.channels.fetch("abc123deadbeef")
reacord.createChannelMessage(channel).render("Hello, world!")
}) })
``` ```
The `.send()` function creates a **Reacord instance**. You can pass strings, numbers, or anything that can be rendered by React, such as JSX! The `.createChannelMessage()` function creates a **Reacord instance**. You can pass strings, numbers, or anything that can be rendered by React, such as JSX!
Components rendered through this instance can include state and effects, and the message on Discord will update automatically. Components rendered through this instance can include state and effects, and the message on Discord will update automatically.
```jsx ```jsx
function Uptime() { function Uptime() {
const [startTime] = useState(Date.now()) const [startTime] = useState(Date.now())
const [currentTime, setCurrentTime] = useState(Date.now()) const [currentTime, setCurrentTime] = useState(Date.now())
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setCurrentTime(Date.now()) setCurrentTime(Date.now())
}, 3000) }, 3000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [])
return <>this message has been shown for {currentTime - startTime}ms</> return <>this message has been shown for {currentTime - startTime}ms</>
} }
client.on("ready", () => { client.on("ready", () => {
reacord.send(channelId, <Uptime />) const instance = reacord.createChannelMessage(channel)
instance.render(<Uptime />)
}) })
``` ```
@@ -46,12 +46,26 @@ The instance can be rendered to multiple times, which will update the message ea
const Hello = ({ subject }) => <>Hello, {subject}!</> const Hello = ({ subject }) => <>Hello, {subject}!</>
client.on("ready", () => { client.on("ready", () => {
const instance = reacord.send(channel) const instance = reacord.createChannelMessage(channel)
instance.render(<Hello subject="World" />) instance.render(<Hello subject="World" />)
instance.render(<Hello subject="Moon" />) instance.render(<Hello subject="Moon" />)
}) })
``` ```
You can specify various options for the message:
```jsx
const instance = reacord.createChannelMessage(channel, {
tts: true,
reply: {
messageReference: someMessage.id,
},
flags: [MessageFlags.SuppressNotifications],
})
```
See the [Discord.js docs](https://discord.js.org/#/docs/discord.js/main/typedef/MessageCreateOptions) for all of the available options.
## Cleaning Up Instances ## Cleaning Up Instances
If you no longer want to use the instance, you can clean it up in a few ways: If you no longer want to use the instance, you can clean it up in a few ways:
@@ -63,9 +77,9 @@ By default, Reacord has a max limit on the number of active instances, and deact
```js ```js
const reacord = new ReacordDiscordJs(client, { const reacord = new ReacordDiscordJs(client, {
// after sending four messages, // after sending four messages,
// the first one will be deactivated // the first one will be deactivated
maxInstances: 3, maxInstances: 3,
}) })
``` ```
@@ -75,28 +89,28 @@ const reacord = new ReacordDiscordJs(client, {
This section also applies to other kinds of application commands, such as context menu commands. This section also applies to other kinds of application commands, such as context menu commands.
</aside> </aside>
To reply to a command interaction, use the `.reply()` function. This function returns an instance that works the same way as the one from `.send()`. Here's an example: To reply to a command interaction, use the `.createInteractionReply()` function. This function returns an instance that works the same way as the one from `.createChannelMessage()`. Here's an example:
```jsx ```jsx
import { Client } from "discord.js" import { Client } from "discord.js"
import * as React from "react"
import { Button, ReacordDiscordJs } from "reacord" import { Button, ReacordDiscordJs } from "reacord"
import * as React from "react"
const client = new Client({ intents: [] }) const client = new Client({ intents: [] })
const reacord = new ReacordDiscordJs(client) const reacord = new ReacordDiscordJs(client)
client.on("ready", () => { client.on("ready", () => {
client.application?.commands.create({ client.application?.commands.create({
name: "ping", name: "ping",
description: "pong!", description: "pong!",
}) })
}) })
client.on("interactionCreate", (interaction) => { client.on("interactionCreate", (interaction) => {
if (interaction.isCommand() && interaction.commandName === "ping") { if (interaction.isCommand() && interaction.commandName === "ping") {
// Use the reply() function instead of send // Use the createInteractionReply() function instead of createChannelMessage
reacord.reply(interaction, <>pong!</>) reacord.createInteractionReply(interaction).render(<>pong!</>)
} }
}) })
client.login(process.env.DISCORD_TOKEN) client.login(process.env.DISCORD_TOKEN)
@@ -110,57 +124,75 @@ However, the process of creating commands can get really repetitive and error-pr
```jsx ```jsx
function handleCommands(client, commands) { function handleCommands(client, commands) {
client.on("ready", () => { client.on("ready", () => {
for (const { name, description } of commands) { for (const { name, description } of commands) {
client.application?.commands.create({ name, description }) client.application?.commands.create({ name, description })
} }
}) })
client.on("interactionCreate", (interaction) => { client.on("interactionCreate", (interaction) => {
if (interaction.isCommand()) { if (interaction.isCommand()) {
for (const command of commands) { for (const command of commands) {
if (interaction.commandName === command.name) { if (interaction.commandName === command.name) {
command.run(interaction) command.run(interaction)
} }
} }
} }
}) })
} }
``` ```
```jsx ```jsx
handleCommands(client, [ handleCommands(client, [
{ {
name: "ping", name: "ping",
description: "pong!", description: "pong!",
run: (interaction) => { run: (interaction) => {
reacord.reply(interaction, <>pong!</>) reacord.createInteractionReply(interaction).render(<>pong!</>)
}, },
}, },
{ {
name: "hi", name: "hi",
description: "say hi", description: "say hi",
run: (interaction) => { run: (interaction) => {
reacord.reply(interaction, <>hi</>) reacord.createInteractionReply(interaction).render(<>hi</>)
}, },
}, },
]) ])
``` ```
## Ephemeral Command Replies ## Ephemeral Command Replies
Ephemeral replies are replies that only appear for one user. To create them, use the `.ephemeralReply()` function. Ephemeral replies are replies that only appear for one user. To create them, use the `.createInteractionReply()` function and provide `ephemeral` option.
```tsx ```jsx
handleCommands(client, [ handleCommands(client, [
{ {
name: "pong", name: "pong",
description: "pong, but in secret", description: "pong, but in secret",
run: (interaction) => { run: (interaction) => {
reacord.ephemeralReply(interaction, <>(pong)</>) reacord
}, .createInteractionReply(interaction, { ephemeral: true })
}, .render(<>(pong)</>)
},
},
]) ])
``` ```
The `ephemeralReply` function also returns an instance, but ephemeral replies cannot be updated via `instance.render()`. You can `.deactivate()` them, but `.destroy()` will not delete the message; only the user can hide it from view. ## Text-to-Speech Command Replies
Additionally interaction replies may have `tts` option to turn on text-to-speech ability for the reply. To create such reply, use `.createInteractionReply()` function and provide `tts` option.
```jsx
handleCommands(client, [
{
name: "pong",
description: "pong, but converted into audio",
run: (interaction) => {
reacord
.createInteractionReply(interaction, { tts: true })
.render(<>pong!</>)
},
},
])
```

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