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",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
"$schema": "https://unpkg.com/@changesets/config@2.1.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"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
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v3
- name: setup pnpm
uses: pnpm/action-setup@v2
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: 7.13.4
- name: setup node
uses: actions/setup-node@v3
version: 8
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
cache: pnpm
- name: install deps
run: pnpm install --frozen-lockfile
- run: pnpm install --frozen-lockfile
- name: changesets release
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
*.code-workspace
.pnpm-debug.log
build
.cache
.vercel
.vercel
*.tsbuildinfo

View File

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

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>
<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>
## 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",
"private": true,
"scripts": {
"lint": "eslint --ext js,ts,tsx .",
"lint-fix": "pnpm lint -- --fix",
"format": "prettier --write .",
"lint": "run-s --continue-on-error lint:*",
"lint:eslint": "eslint . --fix --cache --cache-file=node_modules/.cache/.eslintcache --report-unused-disable-directives",
"lint:prettier": "prettier . \"**/*.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:website": "pnpm --filter website... run build",
"start": "pnpm -C packages/website run start",
"start:website": "pnpm -C packages/website run start",
"release": "pnpm -r run build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.25.0",
"@itsmapleleaf/configs": "^1.1.7",
"@rushstack/eslint-patch": "^1.2.0",
"@types/eslint": "^8.4.6",
"astro-eslint-parser": "^0.12.0",
"eslint": "^8.36.0",
"prettier": "^2.7.1",
"prettier-plugin-astro": "^0.8.0",
"typescript": "^4.8.4"
"@changesets/cli": "^2.26.2",
"@itsmapleleaf/configs": "github:itsMapleLeaf/configs",
"eslint": "^8.51.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.0.3",
"react": "^18.2.0",
"tailwindcss": "^3.3.3",
"typescript": "^5.2.2",
"vitest": "^0.34.6"
},
"resolutions": {
"esbuild": "latest"
"prettier": "@itsmapleleaf/configs/prettier",
"eslintConfig": {
"extends": [
"./node_modules/@itsmapleleaf/configs/eslint.config.cjs",
"./node_modules/@itsmapleleaf/configs/eslint.config.react.cjs"
],
"ignorePatterns": [
"node_modules",
"dist",
"packages/website/public/api"
],
"rules": {
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/require-await": "off"
}
}
}

View File

@@ -1,42 +1,42 @@
import { camelCaseDeep, snakeCaseDeep } from "./convert-object-property-case"
import type {
CamelCasedPropertiesDeep,
SnakeCasedPropertiesDeep,
CamelCasedPropertiesDeep,
SnakeCasedPropertiesDeep,
} from "type-fest"
import { expect, test } from "vitest"
import { camelCaseDeep, snakeCaseDeep } from "./convert-object-property-case"
test("camelCaseDeep", () => {
const input = {
some_prop: {
some_deep_prop: "some_deep_value",
},
someOtherProp: "someOtherValue",
}
const input = {
some_prop: {
some_deep_prop: "some_deep_value",
},
someOtherProp: "someOtherValue",
}
const expected: CamelCasedPropertiesDeep<typeof input> = {
someProp: {
someDeepProp: "some_deep_value",
},
someOtherProp: "someOtherValue",
}
const expected: CamelCasedPropertiesDeep<typeof input> = {
someProp: {
someDeepProp: "some_deep_value",
},
someOtherProp: "someOtherValue",
}
expect(camelCaseDeep(input)).toEqual(expected)
expect(camelCaseDeep(input)).toEqual(expected)
})
test("snakeCaseDeep", () => {
const input = {
someProp: {
someDeepProp: "someDeepValue",
},
some_other_prop: "someOtherValue",
}
const input = {
someProp: {
someDeepProp: "someDeepValue",
},
some_other_prop: "someOtherValue",
}
const expected: SnakeCasedPropertiesDeep<typeof input> = {
some_prop: {
some_deep_prop: "someDeepValue",
},
some_other_prop: "someOtherValue",
}
const expected: SnakeCasedPropertiesDeep<typeof input> = {
some_prop: {
some_deep_prop: "someDeepValue",
},
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 type {
CamelCasedPropertiesDeep,
SnakeCasedPropertiesDeep,
CamelCasedPropertiesDeep,
SnakeCasedPropertiesDeep,
UnknownRecord,
} from "type-fest"
function convertKeyCaseDeep<Input, Output>(
input: Input,
convertKey: (key: string) => string,
input: Input,
convertKey: (key: string) => string,
): Output {
if (!isObject(input)) {
return input as unknown as Output
}
if (!isObject(input)) {
return input as unknown as Output
}
if (Array.isArray(input)) {
return input.map((item) =>
convertKeyCaseDeep(item, convertKey),
) as unknown as Output
}
if (Array.isArray(input)) {
return input.map((item) =>
convertKeyCaseDeep(item, convertKey),
) as unknown as Output
}
const output: any = {}
for (const [key, value] of Object.entries(input)) {
output[convertKey(key)] = convertKeyCaseDeep(value, convertKey)
}
return output
const output = {} as UnknownRecord
for (const [key, value] of Object.entries(input)) {
output[convertKey(key)] = convertKeyCaseDeep(value, convertKey)
}
return output as Output
}
export function camelCaseDeep<T>(input: T): CamelCasedPropertiesDeep<T> {
return convertKeyCaseDeep(input, camelCase)
return convertKeyCaseDeep(input, camelCase)
}
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"
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 =
<T>(Constructor: new (...args: any[]) => T) =>
(value: unknown): value is T =>
value instanceof Constructor
<Instance, Args extends unknown[]>(
constructor: new (...args: Args) => Instance,
) =>
(value: unknown): value is Instance =>
value instanceof constructor

View File

@@ -1,7 +1,3 @@
export function isObject<T>(
value: T,
): value is Exclude<T, Primitive | AnyFunction> {
return typeof value === "object" && value !== null
export function isObject(value: unknown): value is object {
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 {
return array[array.length - 1]
return array[array.length - 1]
}

View File

@@ -1,11 +1,11 @@
import { inspect } from "node:util"
export function logPretty(value: unknown) {
console.info(
inspect(value, {
// depth: Number.POSITIVE_INFINITY,
depth: 10,
colors: true,
}),
)
console.info(
inspect(value, {
// depth: Number.POSITIVE_INFINITY,
depth: 10,
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>(
subject: Subject,
keys: Key[],
// hack: using a conditional type preserves union types
): Subject extends any ? Omit<Subject, Key> : never {
const result: any = {}
for (const key in subject) {
if (!keys.includes(key as unknown as Key)) {
result[key] = subject[key]
}
}
return result
subject: Subject,
keys: Key[],
) {
const keySet = new Set<PropertyKey>(keys)
return Object.fromEntries(
Object.entries(subject).filter(([key]) => !keySet.has(key)),
// hack: conditional type preserves unions
) as Subject extends unknown ? Omit<Subject, Key> : never
}

View File

@@ -1,11 +1,15 @@
{
"name": "@reacord/helpers",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"typecheck": "tsc -b"
},
"dependencies": {
"@types/lodash-es": "^4.17.6",
"@types/lodash-es": "^4.17.9",
"lodash-es": "^4.17.21",
"type-fest": "^2.17.0",
"vitest": "^0.18.1"
"type-fest": "^4.4.0",
"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>(
object: T,
keys: K[],
): LoosePick<T, K> {
const result: any = {}
for (const key of keys) {
const value = (object as UnknownRecord)[key]
if (value !== undefined) {
result[key] = value
}
}
return result
export function pick<T extends object, K extends keyof T | PropertyKey>(
object: T,
keys: K[],
) {
const keySet = new Set<PropertyKey>(keys)
return Object.fromEntries(
Object.entries(object).filter(([key]) => keySet.has(key)),
) as LoosePick<T, K>
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
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 */
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>
? Value
: Type[keyof Type]
? Value
: 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> = {
[Key in Keys]: Shape extends Record<Key, infer Value> ? Value : never
}
export type LooseOmit<Shape, Keys extends PropertyKey> = Simplify<{
[Key in Exclude<keyof Shape, Keys>]: Shape[Key]
}>
export type Simplify<T> = { [Key in keyof T]: T[Key] } & NonNullable<unknown>
export const typeEquals = <A, B>(
_result: A extends B ? (B extends A ? true : false) : false,
) => raise("typeEquals() should not be called at runtime")

View File

@@ -1,21 +1,23 @@
import { setTimeout } from "node:timers/promises"
import type { MaybePromise } from "./types.ts"
const maxTime = 1000
export async function waitFor<Result>(
predicate: () => Result,
predicate: () => MaybePromise<Result>,
): Promise<Awaited<Result>> {
const startTime = Date.now()
let lastError: unknown
const startTime = Date.now()
let lastError: unknown
while (Date.now() - startTime < maxTime) {
try {
return await predicate()
} catch (error) {
lastError = error
await setTimeout(50)
}
}
while (Date.now() - startTime < maxTime) {
try {
return await predicate()
} catch (error) {
lastError = error
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"
export function withLoggedMethodCalls<T extends object>(value: T) {
return new Proxy(value as Record<string | symbol, unknown>, {
get(target, property) {
const value = target[property]
if (typeof value !== "function") {
return value
}
return (...values: any[]) => {
console.info(
`${String(property)}(${values
.map((value) =>
typeof value === "object" && value !== null
? value.constructor.name
: inspect(value, { colors: true }),
)
.join(", ")})`,
)
return value.apply(target, values)
}
},
}) as T
return new Proxy(value as Record<string | symbol, unknown>, {
get(target, property) {
const value = target[property]
if (typeof value !== "function") {
return value
}
return (...values: unknown[]) => {
console.info(
`${String(property)}(${values
.map((value) =>
typeof value === "object" && value !== null
? value.constructor.name
: inspect(value, { colors: true }),
)
.join(", ")})`,
)
return value.apply(target, values) as unknown
}
},
}) as T
}

View File

@@ -1,5 +1,67 @@
# 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
### 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 { ReacordInstance } from "./instance"
/**
* @category Component Event
*/
export type ComponentEvent = {
/**
* The message associated with this event.
* For example: with a button click,
* this is the message that the button is on.
* @see https://discord.com/developers/docs/resources/channel#message-object
*/
message: MessageInfo
/** @category Component Event */
export interface ComponentEvent {
/**
* The message associated with this event. For example: with a button click,
* this is the message that the button is on.
*
* @see https://discord.com/developers/docs/resources/channel#message-object
*/
message: ComponentEventMessage
/**
* The channel that this event occurred in.
* @see https://discord.com/developers/docs/resources/channel#channel-object
*/
channel: ChannelInfo
/**
* The channel that this event occurred in.
*
* @see https://discord.com/developers/docs/resources/channel#channel-object
*/
channel: ComponentEventChannel
/**
* The user that triggered this event.
* @see https://discord.com/developers/docs/resources/user#user-object
*/
user: UserInfo
/**
* The user that triggered this event.
*
* @see https://discord.com/developers/docs/resources/user#user-object
*/
user: ComponentEventUser
/**
* The guild that this event occurred in.
* @see https://discord.com/developers/docs/resources/guild#guild-object
*/
guild?: GuildInfo
/**
* The guild that this event occurred in.
*
* @see https://discord.com/developers/docs/resources/guild#guild-object
*/
guild?: ComponentEventGuild
/**
* Create a new reply to this event.
*/
reply(content?: ReactNode): ReacordInstance
/** Create a new reply to this event. */
reply(
content?: ReactNode,
options?: ComponentEventReplyOptions,
): ReacordInstance
/**
* Create an ephemeral reply to this event,
* shown only to the user who triggered it.
*/
ephemeralReply(content?: ReactNode): ReacordInstance
/**
* Create an ephemeral reply to this event, shown only to the user who
* triggered it.
*
* @deprecated Use event.reply(content, { ephemeral: true })
*/
ephemeralReply(content?: ReactNode): ReacordInstance
}
/**
* @category Component Event
*/
export type ChannelInfo = {
id: string
name?: string
topic?: string
nsfw?: boolean
lastMessageId?: string
ownerId?: string
parentId?: string
rateLimitPerUser?: number
/** @category Component Event */
export interface ComponentEventReplyOptions {
ephemeral?: boolean
tts?: boolean
}
/**
* @category Component Event
*/
export type MessageInfo = {
id: string
channelId: string
authorId: UserInfo
member?: GuildMemberInfo
content: string
timestamp: string
editedTimestamp?: string
tts: boolean
mentionEveryone: boolean
/** The IDs of mentioned users */
mentions: string[]
/** @category Component Event */
export interface ComponentEventChannel {
id: string
name?: string
topic?: string
nsfw?: boolean
lastMessageId?: string
ownerId?: string
parentId?: string
rateLimitPerUser?: number
}
/**
* @category Component Event
*/
export type GuildInfo = {
id: string
name: string
member: GuildMemberInfo
/** @category Component Event */
export interface ComponentEventMessage {
id: string
channelId: string
authorId: string
member?: ComponentEventGuildMember
content: string
timestamp: string
editedTimestamp?: string
tts: boolean
mentionEveryone: boolean
/** The IDs of mentioned users */
mentions: string[]
}
/**
* @category Component Event
*/
export type GuildMemberInfo = {
id: string
nick?: string
displayName: string
avatarUrl?: string
displayAvatarUrl: string
roles: string[]
color: number
joinedAt?: string
premiumSince?: string
pending?: boolean
communicationDisabledUntil?: string
/** @category Component Event */
export interface ComponentEventGuild {
id: string
name: string
member: ComponentEventGuildMember
}
/**
* @category Component Event
*/
export type UserInfo = {
id: string
username: string
discriminator: string
tag: string
avatarUrl: string
accentColor?: number
/** @category Component Event */
export interface ComponentEventGuildMember {
id: string
nick?: string
displayName: string
avatarUrl?: string
displayAvatarUrl: string
roles: string[]
color: number
joinedAt?: string
premiumSince?: string
pending?: boolean
communicationDisabledUntil?: string
}
/** @category Component Event */
export interface 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 React from "react"
import { ReacordElement } from "../../internal/element.js"
import type { MessageOptions } from "../../internal/message"
import { Node } from "../../internal/node.js"
import type { ComponentInteraction } from "../../internal/interaction.js"
/**
* Props for an action row
*
* @category Action Row
*/
export type ActionRowProps = {
children?: ReactNode
export interface ActionRowProps {
children?: ReactNode
}
/**
* An action row is a top-level container for message components.
*
* You don't need to use this; Reacord automatically creates action rows for you.
* But this can be useful if you want a specific layout.
* You don't need to use this; Reacord automatically creates action rows for
* you. But this can be useful if you want a specific layout.
*
* ```tsx
* // put buttons on two separate rows
@@ -30,18 +31,26 @@ export type ActionRowProps = {
* @see https://discord.com/developers/docs/interactions/message-components#action-rows
*/
export function ActionRow(props: ActionRowProps) {
return (
<ReacordElement props={props} createNode={() => new ActionRowNode(props)}>
{props.children}
</ReacordElement>
)
return (
<ReacordElement props={props} createNode={() => new ActionRowNode(props)}>
{props.children}
</ReacordElement>
)
}
class ActionRowNode extends Node<{}> {
override modifyMessageOptions(options: MessageOptions): void {
options.actionRows.push([])
for (const child of this.children) {
child.modifyMessageOptions(options)
}
}
class ActionRowNode extends Node<ActionRowProps> {
override modifyMessageOptions(options: MessageOptions): void {
options.actionRows.push([])
for (const child of this.children) {
child.modifyMessageOptions(options)
}
}
handleComponentInteraction(interaction: ComponentInteraction) {
for (const child of this.children) {
if (child.handleComponentInteraction(interaction)) {
return true
}
}
return false
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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 type { ReacordInstance } from "./instance"
const Context = React.createContext<ReacordInstance | undefined>(undefined)
@@ -13,8 +13,8 @@ export const InstanceProvider = Context.Provider
* @see https://reacord.mapleleaf.dev/guides/use-instance
*/
export function useInstance(): ReacordInstance {
return (
React.useContext(Context) ??
raise("Could not find instance, was this component rendered via Reacord?")
)
return (
React.useContext(Context) ??
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.
*
* @category Core
*/
export type ReacordInstance = {
/** Render some JSX to this instance (edits the message) */
render: (content: ReactNode) => void
export interface ReacordInstance {
/** Render some JSX to this instance (edits the message) */
render: (content: ReactNode) => ReacordInstance
/** Remove this message */
destroy: () => void
/** Remove this message */
destroy: () => void
/**
* Same as destroy, but keeps the message and disables the components on it.
* This prevents it from listening to user interactions.
*/
deactivate: () => void
/**
* Same as destroy, but keeps the message and disables the components on it.
* This prevents it from listening to user interactions.
*/
deactivate: () => void
}

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import type { Node } from "./node"
import type { ReactNode } from "react"
import React from "react"
import type { Node } from "./node"
export function ReacordElement<Props>(props: {
props: Props
createNode: () => Node<Props>
children?: ReactNode
props: Props
createNode: () => Node<Props>
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 ButtonInteraction = BaseComponentInteraction<
"button",
ButtonClickEvent
"button",
ButtonClickEvent
>
export type SelectInteraction = BaseComponentInteraction<
"select",
SelectChangeEvent
"select",
SelectChangeEvent
>
export type BaseInteraction<Type extends string> = {
type: Type
id: string
reply(messageOptions: MessageOptions): Promise<Message>
followUp(messageOptions: MessageOptions): Promise<Message>
export interface BaseInteraction<Type extends string> {
type: Type
id: string
reply(messageOptions: MessageOptions): Promise<Message>
followUp(messageOptions: MessageOptions): Promise<Message>
}
export type BaseComponentInteraction<
Type extends string,
Event extends ComponentEvent,
Type extends string,
Event extends ComponentEvent,
> = BaseInteraction<Type> & {
event: Event
customId: string
update(options: MessageOptions): Promise<void>
deferUpdate(): Promise<void>
event: Event
customId: string
update(options: MessageOptions): Promise<void>
deferUpdate(): Promise<void>
}

View File

@@ -1,24 +1,24 @@
export class LimitedCollection<T> {
private items: T[] = []
private items: T[] = []
constructor(private readonly size: number) {}
constructor(private readonly size: number) {}
add(item: T) {
if (this.items.length >= this.size) {
this.items.shift()
}
this.items.push(item)
}
add(item: T) {
if (this.items.length >= this.size) {
this.items.shift()
}
this.items.push(item)
}
has(item: T) {
return this.items.includes(item)
}
has(item: T) {
return this.items.includes(item)
}
values(): readonly T[] {
return this.items
}
values(): readonly T[] {
return this.items
}
[Symbol.iterator]() {
return this.items[Symbol.iterator]()
}
[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 { SelectProps } from "../core/components/select"
import { last } from "@reacord/helpers/last"
import type { Except } from "type-fest"
export type MessageOptions = {
content: string
embeds: EmbedOptions[]
actionRows: ActionRow[]
export interface MessageOptions {
content: string
embeds: EmbedOptions[]
actionRows: ActionRow[]
}
export type ActionRow = ActionRowItem[]
export type ActionRowItem =
| MessageButtonOptions
| MessageLinkOptions
| MessageSelectOptions
| MessageButtonOptions
| MessageLinkOptions
| MessageSelectOptions
export type MessageButtonOptions = {
type: "button"
customId: string
label?: string
style?: "primary" | "secondary" | "success" | "danger"
disabled?: boolean
emoji?: string
export interface MessageButtonOptions {
type: "button"
customId: string
label?: string
style?: "primary" | "secondary" | "success" | "danger"
disabled?: boolean
emoji?: string
}
export type MessageLinkOptions = {
type: "link"
url: string
label?: string
emoji?: string
disabled?: boolean
export interface MessageLinkOptions {
type: "link"
url: string
label?: string
emoji?: string
disabled?: boolean
}
export type MessageSelectOptions = Except<SelectProps, "children" | "value"> & {
type: "select"
customId: string
options: MessageSelectOptionOptions[]
type: "select"
customId: string
options: MessageSelectOptionOptions[]
}
export type MessageSelectOptionOptions = {
label: string
value: string
description?: string
emoji?: string
export interface MessageSelectOptionOptions {
label: string
value: string
description?: string
emoji?: string
}
export type Message = {
edit(options: MessageOptions): Promise<void>
delete(): Promise<void>
export interface Message {
edit(options: MessageOptions): Promise<void>
delete(): Promise<void>
}
export function getNextActionRow(options: MessageOptions): ActionRow {
let actionRow = last(options.actionRows)
if (
actionRow == undefined ||
actionRow.length >= 5 ||
actionRow[0]?.type === "select"
) {
actionRow = []
options.actionRows.push(actionRow)
}
return actionRow
let actionRow = last(options.actionRows)
if (
actionRow == undefined ||
actionRow.length >= 5 ||
actionRow[0]?.type === "select"
) {
actionRow = []
options.actionRows.push(actionRow)
}
return actionRow
}

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import type { Interaction } from "../interaction"
import type { Message, MessageOptions } from "../message"
import { Renderer } from "./renderer"
@@ -6,17 +5,23 @@ import { Renderer } from "./renderer"
// so we know whether to call reply() or followUp()
const repliedInteractionIds = new Set<string>()
export class InteractionReplyRenderer extends Renderer {
constructor(private interaction: Interaction) {
super()
}
protected createMessage(options: MessageOptions): Promise<Message> {
if (repliedInteractionIds.has(this.interaction.id)) {
return this.interaction.followUp(options)
}
repliedInteractionIds.add(this.interaction.id)
return this.interaction.reply(options)
}
export type InteractionReplyRendererImplementation = {
interactionId: string
reply: (options: MessageOptions) => Promise<Message>
followUp: (options: MessageOptions) => Promise<Message>
}
export class InteractionReplyRenderer extends Renderer {
constructor(private implementation: InteractionReplyRendererImplementation) {
super()
}
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 type { ComponentInteraction } from "../interaction"
import type { Message, MessageOptions } from "../message"
import type { Node } from "../node.js"
import { Subject } from "rxjs"
import { concatMap } from "rxjs/operators"
type UpdatePayload =
| { action: "update" | "deactivate"; options: MessageOptions }
| { action: "deferUpdate"; interaction: ComponentInteraction }
| { action: "destroy" }
| { action: "update" | "deactivate"; options: MessageOptions }
| { action: "deferUpdate"; interaction: ComponentInteraction }
| { action: "destroy" }
export abstract class Renderer {
readonly nodes = new Container<Node<unknown>>()
private componentInteraction?: ComponentInteraction
private message?: Message
private active = true
private updates = new Subject<UpdatePayload>()
readonly nodes = new Container<Node<unknown>>()
private componentInteraction?: ComponentInteraction
private message?: Message
private active = true
private updates = new Subject<UpdatePayload>()
private updateSubscription = this.updates
.pipe(concatMap((payload) => this.updateMessage(payload)))
.subscribe({ error: console.error })
private updateSubscription = this.updates
.pipe(concatMap((payload) => this.updateMessage(payload)))
.subscribe({ error: console.error })
render() {
if (!this.active) {
console.warn("Attempted to update a deactivated message")
return
}
render() {
if (!this.active) {
console.warn("Attempted to update a deactivated message")
return
}
this.updates.next({
options: this.getMessageOptions(),
action: "update",
})
}
this.updates.next({
options: this.getMessageOptions(),
action: "update",
})
}
deactivate() {
this.active = false
this.updates.next({
options: this.getMessageOptions(),
action: "deactivate",
})
}
deactivate() {
this.active = false
this.updates.next({
options: this.getMessageOptions(),
action: "deactivate",
})
}
destroy() {
this.active = false
this.updates.next({ action: "destroy" })
}
destroy() {
this.active = false
this.updates.next({ action: "destroy" })
}
handleComponentInteraction(interaction: ComponentInteraction) {
this.componentInteraction = interaction
handleComponentInteraction(interaction: ComponentInteraction) {
for (const node of this.nodes) {
if (node.handleComponentInteraction(interaction)) {
this.componentInteraction = interaction
setTimeout(() => {
this.updates.next({ action: "deferUpdate", interaction })
}, 500)
return true
}
}
}
setTimeout(() => {
this.updates.next({ action: "deferUpdate", interaction })
}, 500)
protected abstract createMessage(options: MessageOptions): Promise<Message>
for (const node of this.nodes) {
if (node.handleComponentInteraction(interaction)) {
return true
}
}
}
private getMessageOptions(): MessageOptions {
const options: MessageOptions = {
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 {
const options: MessageOptions = {
content: "",
embeds: [],
actionRows: [],
}
for (const node of this.nodes) {
node.modifyMessageOptions(options)
}
return options
}
if (payload.action === "deactivate") {
this.updateSubscription.unsubscribe()
private async updateMessage(payload: UpdatePayload) {
if (payload.action === "destroy") {
this.updateSubscription.unsubscribe()
await this.message?.delete()
return
}
await this.message?.edit({
...payload.options,
actionRows: payload.options.actionRows.map((row) =>
row.map((component) => ({
...component,
disabled: true,
})),
),
})
if (payload.action === "deactivate") {
this.updateSubscription.unsubscribe()
return
}
await this.message?.edit({
...payload.options,
actionRows: payload.options.actionRows.map((row) =>
row.map((component) => ({
...component,
disabled: true,
})),
),
})
if (payload.action === "deferUpdate") {
await payload.interaction.deferUpdate()
return
}
return
}
if (this.componentInteraction) {
const promise = this.componentInteraction.update(payload.options)
this.componentInteraction = undefined
await promise
return
}
if (payload.action === "deferUpdate") {
await payload.interaction.deferUpdate()
return
}
if (this.message) {
await this.message.edit(payload.options)
return
}
if (this.componentInteraction) {
const promise = this.componentInteraction.update(payload.options)
this.componentInteraction = undefined
await promise
return
}
if (this.message) {
await this.message.edit(payload.options)
return
}
this.message = await this.createMessage(payload.options)
}
this.message = await this.createMessage(payload.options)
}
}

View File

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

View File

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

View File

@@ -2,8 +2,7 @@
"name": "reacord",
"type": "module",
"description": "Create interactive Discord messages using React.",
"version": "0.5.2",
"types": "./dist/main.d.ts",
"version": "0.6.0",
"homepage": "https://reacord.mapleleaf.dev",
"repository": "https://github.com/itsMapleLeaf/reacord.git",
"changelog": "https://github.com/itsMapleLeaf/reacord/releases",
@@ -24,6 +23,7 @@
"README.md",
"LICENSE"
],
"types": "./dist/main.d.ts",
"exports": {
".": {
"import": "./dist/main.js",
@@ -36,19 +36,19 @@
}
},
"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",
"test": "vitest --coverage --no-watch",
"test-dev": "vitest",
"test-manual": "nodemon --exec tsx --ext ts,tsx ./scripts/discordjs-manual-test.tsx",
"typecheck": "tsc --noEmit"
"typecheck": "tsc -b"
},
"dependencies": {
"@types/node": "*",
"@types/react": "*",
"@types/react-reconciler": "^0.28.0",
"@types/node": "^20.8.4",
"@types/react": "^18.2.27",
"@types/react-reconciler": "^0.28.5",
"react-reconciler": "^0.29.0",
"rxjs": "^7.5.6"
"rxjs": "^7.8.1"
},
"peerDependencies": {
"discord.js": "^14",
@@ -61,23 +61,19 @@
},
"devDependencies": {
"@reacord/helpers": "workspace:*",
"@types/lodash-es": "^4.17.6",
"c8": "^7.12.0",
"discord.js": "^14.0.3",
"dotenv": "^16.0.1",
"@types/lodash-es": "^4.17.9",
"c8": "^8.0.1",
"cpy-cli": "^5.0.0",
"discord.js": "^14.13.0",
"dotenv": "^16.3.1",
"lodash-es": "^4.17.21",
"nodemon": "^2.0.19",
"prettier": "^2.7.1",
"nodemon": "^3.0.1",
"prettier": "^3.0.3",
"pretty-ms": "^8.0.0",
"react": "^18.2.0",
"tsup": "^6.1.3",
"tsx": "^3.8.0",
"type-fest": "^2.17.0",
"typescript": "^4.7.4",
"vitest": "^0.18.1"
},
"resolutions": {
"esbuild": "latest"
"tsup": "^7.2.0",
"tsx": "^3.13.0",
"type-fest": "^4.4.0"
},
"release-it": {
"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 { ChannelType, Client, IntentsBitField } from "discord.js"
import "dotenv/config"
import { kebabCase } from "lodash-es"
import * as React from "react"
import { useState } from "react"
import {
Button,
Link,
Option,
ReacordDiscordJs,
Select,
useInstance,
} from "../library/main"
const client = new Client({ intents: IntentsBitField.Flags.Guilds })
const reacord = new ReacordDiscordJs(client)
await client.login(process.env.TEST_BOT_TOKEN)
const guild = await client.guilds.fetch(process.env.TEST_GUILD_ID!)
const 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) {
throw new Error(
`channel ${process.env.TEST_CATEGORY_ID} is not a guild category. received ${category?.type}`,
)
throw new Error(
`channel ${process.env.TEST_CATEGORY_ID} is not a guild category. received ${category?.type}`,
)
}
for (const [, channel] of category.children.cache) {
await channel.delete()
await channel.delete()
}
let prefix = 0
const createTest = async (
name: string,
block: (channel: TextChannel) => void | Promise<unknown>,
name: string,
block: (channel: TextChannel) => unknown,
) => {
prefix += 1
const channel = await category.children.create({
type: ChannelType.GuildText,
name: `${String(prefix).padStart(3, "0")}-${kebabCase(name)}`,
})
await block(channel)
prefix += 1
const channel = await category.children.create({
type: ChannelType.GuildText,
name: `${String(prefix).padStart(3, "0")}-${kebabCase(name)}`,
})
await block(channel)
}
await createTest("basic", (channel) => {
reacord.send(channel.id, "Hello, world!")
reacord.createChannelMessage(channel).render("Hello, world!")
})
await createTest("counter", (channel) => {
const Counter = () => {
const [count, setCount] = React.useState(0)
return (
<>
count: {count}
<Button
style="primary"
emoji=""
onClick={() => setCount(count + 1)}
/>
<Button
style="primary"
emoji=""
onClick={() => setCount(count - 1)}
/>
<Button label="reset" onClick={() => setCount(0)} />
</>
)
}
reacord.send(channel.id, <Counter />)
const Counter = () => {
const [count, setCount] = React.useState(0)
return (
<>
count: {count}
<Button
style="primary"
emoji=""
onClick={() => setCount(count + 1)}
/>
<Button
style="primary"
emoji=""
onClick={() => setCount(count - 1)}
/>
<Button label="reset" onClick={() => setCount(0)} />
</>
)
}
reacord.createChannelMessage(channel).render(<Counter />)
})
await createTest("select", (channel) => {
function FruitSelect({ onConfirm }: { onConfirm: (choice: string) => void }) {
const [value, setValue] = useState<string>()
function FruitSelect({ onConfirm }: { onConfirm: (choice: string) => void }) {
const [value, setValue] = useState<string>()
return (
<>
<Select
placeholder="choose a fruit"
value={value}
onChangeValue={setValue}
>
<Option value="🍎" emoji="🍎" label="apple" description="it red" />
<Option value="🍌" emoji="🍌" label="banana" description="bnanbna" />
<Option value="🍒" emoji="🍒" label="cherry" description="heh" />
</Select>
<Button
label="confirm"
disabled={value == undefined}
onClick={() => {
if (value) onConfirm(value)
}}
/>
</>
)
}
return (
<>
<Select
placeholder="choose a fruit"
value={value}
onChangeValue={setValue}
>
<Option value="🍎" emoji="🍎" label="apple" description="it red" />
<Option value="🍌" emoji="🍌" label="banana" description="bnanbna" />
<Option value="🍒" emoji="🍒" label="cherry" description="heh" />
</Select>
<Button
label="confirm"
disabled={value == undefined}
onClick={() => {
if (value) onConfirm(value)
}}
/>
</>
)
}
const instance = reacord.send(
channel.id,
<FruitSelect
onConfirm={(value) => {
instance.render(`you chose ${value}`)
instance.deactivate()
}}
/>,
)
const instance = reacord.createChannelMessage(channel).render(
<FruitSelect
onConfirm={(value) => {
instance.render(`you chose ${value}`)
instance.deactivate()
}}
/>,
)
})
await createTest("ephemeral button", (channel) => {
reacord.send(
channel.id,
<>
<Button
label="public clic"
onClick={(event) =>
event.reply(`${event.guild?.member.displayName} clic`)
}
/>
<Button
label="clic"
onClick={(event) => event.ephemeralReply("you clic")}
/>
</>,
)
reacord.createChannelMessage(channel).render(
<>
<Button
label="public clic"
onClick={(event) =>
event.reply(`${event.guild?.member.displayName} clic`)
}
/>
<Button
label="clic"
onClick={(event) => event.reply("you clic", { ephemeral: true })}
/>
</>,
)
})
await createTest("delete this", (channel) => {
function DeleteThis() {
const instance = useInstance()
return <Button label="delete this" onClick={() => instance.destroy()} />
}
reacord.send(channel.id, <DeleteThis />)
function DeleteThis() {
const instance = useInstance()
return <Button label="delete this" onClick={() => instance.destroy()} />
}
reacord.createChannelMessage(channel).render(<DeleteThis />)
})
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 { ActionRow, Button, Select } from "../library/main"
import { ReacordTester } from "./test-adapter"
@@ -6,36 +5,36 @@ import { ReacordTester } from "./test-adapter"
const testing = new ReacordTester()
test("action row", async () => {
await testing.assertRender(
<>
<Button label="outside button" onClick={() => {}} />
<ActionRow>
<Button label="button inside action row" onClick={() => {}} />
</ActionRow>
<Select />
<Button label="last row 1" onClick={() => {}} />
<Button label="last row 2" onClick={() => {}} />
</>,
[
{
content: "",
embeds: [],
actionRows: [
[{ type: "button", style: "secondary", label: "outside button" }],
[
{
type: "button",
style: "secondary",
label: "button inside action row",
},
],
[{ type: "select", options: [], values: [] }],
[
{ type: "button", style: "secondary", label: "last row 1" },
{ type: "button", style: "secondary", label: "last row 2" },
],
],
},
],
)
await testing.assertRender(
<>
<Button label="outside button" onClick={() => {}} />
<ActionRow>
<Button label="button inside action row" onClick={() => {}} />
</ActionRow>
<Select />
<Button label="last row 1" onClick={() => {}} />
<Button label="last row 2" onClick={() => {}} />
</>,
[
{
content: "",
embeds: [],
actionRows: [
[{ type: "button", style: "secondary", label: "outside button" }],
[
{
type: "button",
style: "secondary",
label: "button inside action row",
},
],
[{ type: "select", options: [], values: [] }],
[
{ type: "button", style: "secondary", label: "last row 1" },
{ type: "button", style: "secondary", label: "last row 2" },
],
],
},
],
)
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,270 +1,270 @@
import * as React from "react"
import { test } from "vitest"
import { Button, Embed, EmbedField, EmbedTitle } from "../library/main"
import { ReacordTester } from "./test-adapter"
import * as React from "react"
import { test } from "vitest"
test("rendering behavior", async () => {
const tester = new ReacordTester()
const tester = new ReacordTester()
const reply = tester.reply()
reply.render(<KitchenSinkCounter onDeactivate={() => reply.deactivate()} />)
const reply = tester
.createInteractionReply()
.render(<KitchenSinkCounter onDeactivate={() => reply.deactivate()} />)
await tester.assertMessages([
{
content: "count: 0",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "secondary",
label: "show embed",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.assertMessages([
{
content: "count: 0",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "secondary",
label: "show embed",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("show embed").click()
await tester.assertMessages([
{
content: "count: 0",
embeds: [{ title: "the counter" }],
actionRows: [
[
{
type: "button",
style: "secondary",
label: "hide embed",
},
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("show embed").click()
await tester.assertMessages([
{
content: "count: 0",
embeds: [{ title: "the counter" }],
actionRows: [
[
{
type: "button",
style: "secondary",
label: "hide embed",
},
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 1",
embeds: [
{
title: "the counter",
fields: [{ name: "is it even?", value: "no" }],
},
],
actionRows: [
[
{
type: "button",
style: "secondary",
label: "hide embed",
},
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 1",
embeds: [
{
title: "the counter",
fields: [{ name: "is it even?", value: "no" }],
},
],
actionRows: [
[
{
type: "button",
style: "secondary",
label: "hide embed",
},
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 2",
embeds: [
{
title: "the counter",
fields: [{ name: "is it even?", value: "yes" }],
},
],
actionRows: [
[
{
type: "button",
style: "secondary",
label: "hide embed",
},
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 2",
embeds: [
{
title: "the counter",
fields: [{ name: "is it even?", value: "yes" }],
},
],
actionRows: [
[
{
type: "button",
style: "secondary",
label: "hide embed",
},
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("hide embed").click()
await tester.assertMessages([
{
content: "count: 2",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "secondary",
label: "show embed",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("hide embed").click()
await tester.assertMessages([
{
content: "count: 2",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "secondary",
label: "show embed",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 3",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "secondary",
label: "show embed",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 3",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
},
{
type: "button",
style: "secondary",
label: "show embed",
},
{
type: "button",
style: "danger",
label: "deactivate",
},
],
],
},
])
await tester.findButtonByLabel("deactivate").click()
await tester.assertMessages([
{
content: "count: 3",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
disabled: true,
},
{
type: "button",
style: "secondary",
label: "show embed",
disabled: true,
},
{
type: "button",
style: "danger",
label: "deactivate",
disabled: true,
},
],
],
},
])
await tester.findButtonByLabel("deactivate").click()
await tester.assertMessages([
{
content: "count: 3",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
disabled: true,
},
{
type: "button",
style: "secondary",
label: "show embed",
disabled: true,
},
{
type: "button",
style: "danger",
label: "deactivate",
disabled: true,
},
],
],
},
])
await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 3",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
disabled: true,
},
{
type: "button",
style: "secondary",
label: "show embed",
disabled: true,
},
{
type: "button",
style: "danger",
label: "deactivate",
disabled: true,
},
],
],
},
])
await tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 3",
embeds: [],
actionRows: [
[
{
type: "button",
style: "primary",
label: "clicc",
disabled: true,
},
{
type: "button",
style: "secondary",
label: "show embed",
disabled: true,
},
{
type: "button",
style: "danger",
label: "deactivate",
disabled: true,
},
],
],
},
])
})
test("delete", async () => {
const tester = new ReacordTester()
const tester = new ReacordTester()
const reply = tester.reply()
reply.render(
<>
some text
<Embed>some embed</Embed>
<Button label="some button" onClick={() => {}} />
</>,
)
const reply = tester.createInteractionReply().render(
<>
some text
<Embed>some embed</Embed>
<Button label="some button" onClick={() => {}} />
</>,
)
await tester.assertMessages([
{
content: "some text",
embeds: [{ description: "some embed" }],
actionRows: [
[{ type: "button", style: "secondary", label: "some button" }],
],
},
])
await tester.assertMessages([
{
content: "some text",
embeds: [{ description: "some embed" }],
actionRows: [
[{ type: "button", style: "secondary", label: "some button" }],
],
},
])
reply.destroy()
await tester.assertMessages([])
reply.destroy()
await tester.assertMessages([])
})
// test multiple instances that can be updated independently,
@@ -272,34 +272,34 @@ test("delete", async () => {
test.todo("multiple instances")
function KitchenSinkCounter(props: { onDeactivate: () => void }) {
const [count, setCount] = React.useState(0)
const [embedVisible, setEmbedVisible] = React.useState(false)
const [count, setCount] = React.useState(0)
const [embedVisible, setEmbedVisible] = React.useState(false)
return (
<>
count: {count}
{embedVisible && (
<Embed>
<EmbedTitle>the counter</EmbedTitle>
{count > 0 && (
<EmbedField name="is it even?">
{count % 2 === 0 ? "yes" : "no"}
</EmbedField>
)}
</Embed>
)}
{embedVisible && (
<Button label="hide embed" onClick={() => setEmbedVisible(false)} />
)}
<Button
style="primary"
label="clicc"
onClick={() => setCount(count + 1)}
/>
{!embedVisible && (
<Button label="show embed" onClick={() => setEmbedVisible(true)} />
)}
<Button style="danger" label="deactivate" onClick={props.onDeactivate} />
</>
)
return (
<>
count: {count}
{embedVisible && (
<Embed>
<EmbedTitle>the counter</EmbedTitle>
{count > 0 && (
<EmbedField name="is it even?">
{count % 2 === 0 ? "yes" : "no"}
</EmbedField>
)}
</Embed>
)}
{embedVisible && (
<Button label="hide embed" onClick={() => setEmbedVisible(false)} />
)}
<Button
style="primary"
label="clicc"
onClick={() => setCount(count + 1)}
/>
{!embedVisible && (
<Button label="show embed" onClick={() => setEmbedVisible(true)} />
)}
<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 { Button, Option, Select } from "../library/main"
import { ReacordTester } from "./test-adapter"
test("single select", async () => {
const tester = new ReacordTester()
const onSelect = vi.fn()
const tester = new ReacordTester()
const onSelect = vi.fn()
function TestSelect() {
const [value, setValue] = useState<string>()
const [disabled, setDisabled] = useState(false)
return (
<>
<Select
placeholder="choose one"
value={value}
onChange={onSelect}
onChangeValue={setValue}
disabled={disabled}
>
<Option value="1" />
<Option value="2" label="two" />
<Option value="3">three</Option>
</Select>
<Button label="disable" onClick={() => setDisabled(true)} />
</>
)
}
function TestSelect() {
const [value, setValue] = useState<string>()
const [disabled, setDisabled] = useState(false)
return (
<>
<Select
placeholder="choose one"
value={value}
onChange={onSelect}
onChangeValue={setValue}
disabled={disabled}
>
<Option value="1" />
<Option value="2" label="two" />
<Option value="3">three</Option>
</Select>
<Button label="disable" onClick={() => setDisabled(true)} />
</>
)
}
async function assertSelect(values: string[], disabled = false) {
await tester.assertMessages([
{
content: "",
embeds: [],
actionRows: [
[
{
type: "select",
placeholder: "choose one",
values,
disabled,
options: [
{ label: "1", value: "1" },
{ label: "two", value: "2" },
{ label: "three", value: "3" },
],
},
],
[{ type: "button", style: "secondary", label: "disable" }],
],
},
])
}
async function assertSelect(values: string[], disabled = false) {
await tester.assertMessages([
{
content: "",
embeds: [],
actionRows: [
[
{
type: "select",
placeholder: "choose one",
values,
disabled,
options: [
{ label: "1", value: "1" },
{ label: "two", value: "2" },
{ label: "three", value: "3" },
],
},
],
[{ type: "button", style: "secondary", label: "disable" }],
],
},
])
}
const reply = tester.reply()
tester.createInteractionReply().render(<TestSelect />)
await assertSelect([])
expect(onSelect).toHaveBeenCalledTimes(0)
reply.render(<TestSelect />)
await assertSelect([])
expect(onSelect).toHaveBeenCalledTimes(0)
await tester.findSelectByPlaceholder("choose one").select("2")
await assertSelect(["2"])
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ values: ["2"] }),
)
await tester.findSelectByPlaceholder("choose one").select("2")
await assertSelect(["2"])
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ values: ["2"] }),
)
await tester.findButtonByLabel("disable").click()
await assertSelect(["2"], true)
await tester.findButtonByLabel("disable").click()
await assertSelect(["2"], true)
await tester.findSelectByPlaceholder("choose one").select("1")
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 () => {
const tester = new ReacordTester()
const onSelect = vi.fn()
const tester = new ReacordTester()
const onSelect = vi.fn()
function TestSelect() {
const [values, setValues] = useState<string[]>([])
return (
<Select
placeholder="select"
multiple
values={values}
onChange={onSelect}
onChangeMultiple={setValues}
>
<Option value="1">one</Option>
<Option value="2">two</Option>
<Option value="3">three</Option>
</Select>
)
}
function TestSelect() {
const [values, setValues] = useState<string[]>([])
return (
<Select
placeholder="select"
multiple
values={values}
onChange={onSelect}
onChangeMultiple={setValues}
>
<Option value="1">one</Option>
<Option value="2">two</Option>
<Option value="3">three</Option>
</Select>
)
}
async function assertSelect(values: string[]) {
await tester.assertMessages([
{
content: "",
embeds: [],
actionRows: [
[
{
type: "select",
placeholder: "select",
values,
minValues: 0,
maxValues: 25,
options: [
{ label: "one", value: "1" },
{ label: "two", value: "2" },
{ label: "three", value: "3" },
],
},
],
],
},
])
}
async function assertSelect(values: string[]) {
await tester.assertMessages([
{
content: "",
embeds: [],
actionRows: [
[
{
type: "select",
placeholder: "select",
values,
minValues: 0,
maxValues: 25,
options: [
{ label: "one", value: "1" },
{ label: "two", value: "2" },
{ label: "three", value: "3" },
],
},
],
],
},
])
}
const reply = tester.reply()
tester.createInteractionReply().render(<TestSelect />)
await assertSelect([])
expect(onSelect).toHaveBeenCalledTimes(0)
reply.render(<TestSelect />)
await assertSelect([])
expect(onSelect).toHaveBeenCalledTimes(0)
await tester.findSelectByPlaceholder("select").select("1", "3")
await assertSelect(expect.arrayContaining(["1", "3"]) as unknown as string[])
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({
values: expect.arrayContaining(["1", "3"]) as unknown,
}),
)
await tester.findSelectByPlaceholder("select").select("1", "3")
await assertSelect(expect.arrayContaining(["1", "3"]) as unknown as string[])
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ values: expect.arrayContaining(["1", "3"]) }),
)
await tester.findSelectByPlaceholder("select").select("2")
await assertSelect(expect.arrayContaining(["2"]) as unknown as string[])
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({
values: expect.arrayContaining(["2"]) as unknown,
}),
)
await tester.findSelectByPlaceholder("select").select("2")
await assertSelect(expect.arrayContaining(["2"]) as unknown as string[])
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ values: expect.arrayContaining(["2"]) }),
)
await tester.findSelectByPlaceholder("select").select()
await assertSelect([])
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ values: [] }))
await tester.findSelectByPlaceholder("select").select()
await assertSelect([])
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ values: [] }))
})
test("optional onSelect + unknown value", async () => {
const tester = new ReacordTester()
tester.reply().render(<Select placeholder="select" />)
await tester.findSelectByPlaceholder("select").select("something")
await tester.assertMessages([
{
content: "",
embeds: [],
actionRows: [
[{ type: "select", placeholder: "select", options: [], values: [] }],
],
},
])
const tester = new ReacordTester()
tester.createInteractionReply().render(<Select placeholder="select" />)
await tester.findSelectByPlaceholder("select").select("something")
await tester.assertMessages([
{
content: "",
embeds: [],
actionRows: [
[{ type: "select", placeholder: "select", options: [], values: [] }],
],
},
])
})
test.todo("select minValues and maxValues")

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,37 @@
# 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
### 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 react from "@astrojs/react"
import tailwind from "@astrojs/tailwind"
@@ -5,13 +7,14 @@ import { defineConfig } from "astro/config"
// https://astro.build/config
export default defineConfig({
integrations: [
tailwind({
config: {
applyBaseStyles: false,
},
}),
react(),
prefetch(),
],
integrations: [
tailwind({
applyBaseStyles: false,
}),
react(),
prefetch(),
],
markdown: {
shikiConfig: {},
},
})

View File

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

View File

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

View File

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

View File

@@ -3,193 +3,199 @@ import { useEffect, useRef, useState } from "react"
import blobComfyUrl from "~/assets/blob-comfy.png"
import cursorIbeamUrl from "~/assets/cursor-ibeam.png"
import cursorUrl from "~/assets/cursor.png"
import { raise } from "@reacord/helpers/raise.ts"
const defaultState = {
chatInputText: "",
chatInputCursorVisible: true,
messageVisible: false,
count: 0,
cursorLeft: "25%",
cursorBottom: "-15px",
chatInputText: "",
chatInputCursorVisible: true,
messageVisible: false,
count: 0,
cursorLeft: "25%",
cursorBottom: "-15px",
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const animationFrame = () =>
new Promise((resolve) => requestAnimationFrame(resolve))
new Promise((resolve) => requestAnimationFrame(resolve))
export function LandingAnimation() {
const [state, setState] = useState(defaultState)
const chatInputRef = useRef<HTMLDivElement>(null)
const addRef = useRef<HTMLDivElement>(null)
const deleteRef = useRef<HTMLDivElement>(null)
const cursorRef = useRef<HTMLImageElement>(null)
const [state, setState] = useState(defaultState)
const chatInputRef = useRef<HTMLDivElement>(null)
const addRef = useRef<HTMLDivElement>(null)
const deleteRef = useRef<HTMLDivElement>(null)
const cursorRef = useRef<HTMLImageElement>(null)
useEffect(() => {
const animateClick = (element: HTMLElement) =>
element.animate(
[{ transform: `translateY(2px)` }, { transform: `translateY(0px)` }],
300,
)
useEffect(() => {
const animateClick = (element: HTMLElement) =>
element.animate(
[{ transform: `translateY(2px)` }, { transform: `translateY(0px)` }],
300,
)
let running = true
let running = true
void (async () => {
while (running) {
setState(defaultState)
await delay(700)
void (async () => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (running) {
setState(defaultState)
await delay(700)
for (const letter of "/counter") {
setState((state) => ({
...state,
chatInputText: state.chatInputText + letter,
}))
await delay(100)
}
for (const letter of "/counter") {
setState((state) => ({
...state,
chatInputText: state.chatInputText + letter,
}))
await delay(100)
}
await delay(1000)
await delay(1000)
setState((state) => ({
...state,
messageVisible: true,
chatInputText: "",
}))
await delay(1000)
setState((state) => ({
...state,
messageVisible: true,
chatInputText: "",
}))
await delay(1000)
setState((state) => ({
...state,
cursorLeft: "70px",
cursorBottom: "40px",
}))
await delay(1500)
setState((state) => ({
...state,
cursorLeft: "70px",
cursorBottom: "40px",
}))
await delay(1500)
for (let i = 0; i < 3; i++) {
setState((state) => ({
...state,
count: state.count + 1,
chatInputCursorVisible: false,
}))
animateClick(addRef.current!)
await delay(700)
}
for (let i = 0; i < 3; i++) {
setState((state) => ({
...state,
count: state.count + 1,
chatInputCursorVisible: false,
}))
animateClick(addRef.current ?? raise("addRef is null"))
await delay(700)
}
await delay(500)
await delay(500)
setState((state) => ({
...state,
cursorLeft: "140px",
}))
await delay(1000)
setState((state) => ({
...state,
cursorLeft: "140px",
}))
await delay(1000)
animateClick(deleteRef.current!)
setState((state) => ({ ...state, messageVisible: false }))
await delay(1000)
animateClick(deleteRef.current ?? raise("deleteRef is null"))
setState((state) => ({ ...state, messageVisible: false }))
await delay(1000)
setState(() => ({
...defaultState,
chatInputCursorVisible: false,
}))
await delay(500)
}
})()
setState(() => ({
...defaultState,
chatInputCursorVisible: false,
}))
await delay(500)
}
})()
return () => {
running = false
}
}, [])
return () => {
running = false
}
}, [])
useEffect(() => {
let running = true
useEffect(() => {
let running = true
void (async () => {
while (running) {
// check if the cursor is in the input
const cursorRect = cursorRef.current!.getBoundingClientRect()
const chatInputRect = chatInputRef.current!.getBoundingClientRect()
void (async () => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (running) {
const cursor = cursorRef.current ?? raise("cursorRef is null")
const chatInput = chatInputRef.current ?? raise("chatInputRef is null")
const isOverInput =
cursorRef.current &&
chatInputRef.current &&
cursorRect.top + cursorRect.height / 2 > chatInputRect.top
// check if the cursor is in the input
const cursorRect = cursor.getBoundingClientRect()
const chatInputRect = chatInput.getBoundingClientRect()
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 () => {
running = false
}
})
await animationFrame()
}
})()
return (
<div
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>
return () => {
running = false
}
})
<img
src={cursorUrl}
alt=""
className="transition-all duration-500 absolute scale-75 bg-transparent"
style={{ left: state.cursorLeft, bottom: state.cursorBottom }}
ref={cursorRef}
/>
</div>
)
return (
<div
className="animate-fade-in pointer-events-none relative grid select-none gap-2"
role="presentation"
>
<div
className={clsx(
"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 bannerUrl from "~/assets/banner.png"
import faviconUrl from "~/assets/favicon.png"
import "~/styles/prism-theme.css"
import "~/styles/tailwind.css"
---
<!DOCTYPE html>
<!doctype html>
<html lang="en" class="bg-slate-900 text-slate-100">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={packageJson.description} />
<meta name="theme-color" content="#21754b" />
<meta property="og:url" content="https://reacord.mapleleaf.dev/" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Reacord" />
<meta
property="og:description"
content="Create interactive Discord messages using React"
/>
<meta property="og:image" content={bannerUrl} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:domain" content="reacord.mapleleaf.dev" />
<meta name="twitter:url" content="https://reacord.mapleleaf.dev/" />
<meta name="twitter:title" content="Reacord" />
<meta
name="twitter:description"
content="Create interactive Discord messages using React"
/>
<meta name="twitter:image" content={bannerUrl} />
<meta name="description" content={packageJson.description} />
<meta name="theme-color" content="#21754b" />
<meta property="og:url" content="https://reacord.mapleleaf.dev/" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Reacord" />
<meta
property="og:description"
content="Create interactive Discord messages using React"
/>
<meta property="og:image" content={bannerUrl} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:domain" content="reacord.mapleleaf.dev" />
<meta name="twitter:url" content="https://reacord.mapleleaf.dev/" />
<meta name="twitter:title" content="Reacord" />
<meta
name="twitter:description"
content="Create interactive Discord messages using React"
/>
<meta name="twitter:image" content={bannerUrl} />
<title>Reacord</title>
<title>Reacord</title>
<link rel="icon" href={faviconUrl} />
</head>
<body>
<slot />
</body>
<link rel="icon" href={faviconUrl} />
</head>
<body>
<slot />
</body>
</html>

View File

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

View File

@@ -1,13 +1,13 @@
---
export type Props = {
icon: (props: { class?: string; className?: string }) => any
label: string
export interface Props {
icon: (props: { class?: string; className?: string }) => unknown
label: string
}
---
<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" />
<span class="flex-1">{Astro.props.label}</span>
<Astro.props.icon class="inline-icon" className="inline-icon" />
<span class="flex-1">{Astro.props.label}</span>
</div>

View File

@@ -1,30 +1,30 @@
<details class="md:hidden relative" data-menu>
<summary
class="list-none p-2 -m-2 cursor-pointer hover:text-emerald-500 transition"
>
<slot name="button" />
</summary>
<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"
>
<slot />
</div>
<details class="relative md:hidden" data-menu>
<summary
class="-m-2 cursor-pointer list-none p-2 transition hover:text-emerald-500"
>
<slot name="button" />
</summary>
<div
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 />
</div>
</details>
<script>
for (const menu of document.querySelectorAll<HTMLDetailsElement>(
"[data-menu]",
)) {
window.addEventListener("click", (event) => {
if (!menu.contains(event.target as Node)) {
menu.open = false
}
})
menu.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
menu.open = false
menu.querySelector("summary")!.focus()
}
})
}
for (const menu of document.querySelectorAll<HTMLDetailsElement>(
"[data-menu]",
)) {
window.addEventListener("click", (event) => {
if (!menu.contains(event.target as Node)) {
menu.open = false
}
})
menu.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
menu.open = false
menu.querySelector("summary")!.focus()
}
})
}
</script>

View File

@@ -1,6 +1,6 @@
---
export type Props = astroHTML.JSX.AnchorHTMLAttributes & {
href: string
href: string
}
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 isActive =
removeTrailingSlash(Astro.url.pathname) ===
removeTrailingSlash(linkUrl.pathname)
removeTrailingSlash(Astro.url.pathname) ===
removeTrailingSlash(linkUrl.pathname)
---
<a {...Astro.props} data-active={isActive || undefined}>
<slot />
<slot />
</a>

View File

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

View File

@@ -6,7 +6,7 @@ slug: 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
@@ -38,7 +38,7 @@ const client = new Client()
const reacord = new ReacordDiscordJs(client)
client.on("ready", () => {
console.log("Ready!")
console.log("Ready!")
})
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):
```bash
npm install tsx
tsx main.tsx
npm install -D 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.
```jsx
const channelId = "abc123deadbeef"
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.
```jsx
function Uptime() {
const [startTime] = useState(Date.now())
const [currentTime, setCurrentTime] = useState(Date.now())
const [startTime] = useState(Date.now())
const [currentTime, setCurrentTime] = useState(Date.now())
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(Date.now())
}, 3000)
return () => clearInterval(interval)
}, [])
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(Date.now())
}, 3000)
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", () => {
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}!</>
client.on("ready", () => {
const instance = reacord.send(channel)
instance.render(<Hello subject="World" />)
instance.render(<Hello subject="Moon" />)
const instance = reacord.createChannelMessage(channel)
instance.render(<Hello subject="World" />)
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
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
const reacord = new ReacordDiscordJs(client, {
// after sending four messages,
// the first one will be deactivated
maxInstances: 3,
// after sending four messages,
// the first one will be deactivated
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.
</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
import { Client } from "discord.js"
import * as React from "react"
import { Button, ReacordDiscordJs } from "reacord"
import * as React from "react"
const client = new Client({ intents: [] })
const reacord = new ReacordDiscordJs(client)
client.on("ready", () => {
client.application?.commands.create({
name: "ping",
description: "pong!",
})
client.application?.commands.create({
name: "ping",
description: "pong!",
})
})
client.on("interactionCreate", (interaction) => {
if (interaction.isCommand() && interaction.commandName === "ping") {
// Use the reply() function instead of send
reacord.reply(interaction, <>pong!</>)
}
if (interaction.isCommand() && interaction.commandName === "ping") {
// Use the createInteractionReply() function instead of createChannelMessage
reacord.createInteractionReply(interaction).render(<>pong!</>)
}
})
client.login(process.env.DISCORD_TOKEN)
@@ -110,57 +124,75 @@ However, the process of creating commands can get really repetitive and error-pr
```jsx
function handleCommands(client, commands) {
client.on("ready", () => {
for (const { name, description } of commands) {
client.application?.commands.create({ name, description })
}
})
client.on("ready", () => {
for (const { name, description } of commands) {
client.application?.commands.create({ name, description })
}
})
client.on("interactionCreate", (interaction) => {
if (interaction.isCommand()) {
for (const command of commands) {
if (interaction.commandName === command.name) {
command.run(interaction)
}
}
}
})
client.on("interactionCreate", (interaction) => {
if (interaction.isCommand()) {
for (const command of commands) {
if (interaction.commandName === command.name) {
command.run(interaction)
}
}
}
})
}
```
```jsx
handleCommands(client, [
{
name: "ping",
description: "pong!",
run: (interaction) => {
reacord.reply(interaction, <>pong!</>)
},
},
{
name: "hi",
description: "say hi",
run: (interaction) => {
reacord.reply(interaction, <>hi</>)
},
},
{
name: "ping",
description: "pong!",
run: (interaction) => {
reacord.createInteractionReply(interaction).render(<>pong!</>)
},
},
{
name: "hi",
description: "say hi",
run: (interaction) => {
reacord.createInteractionReply(interaction).render(<>hi</>)
},
},
])
```
## 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, [
{
name: "pong",
description: "pong, but in secret",
run: (interaction) => {
reacord.ephemeralReply(interaction, <>(pong)</>)
},
},
{
name: "pong",
description: "pong, but in secret",
run: (interaction) => {
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