163 Commits

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

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

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

so far it looks like I can reduce this to: elements -> node tree -> adapter renderer
2022-07-24 15:02:07 -05:00
itsMapleLeaf cfd88fe110 fix test 2022-07-24 13:46:08 -05:00
itsMapleLeaf a9b5e4c380 rename files appropriately 2022-07-24 13:42:21 -05:00
itsMapleLeaf f9564897aa classes are fine, actually! + simplified things more 2022-07-24 13:39:55 -05:00
itsMapleLeaf 533d8a0f60 add back reconciler generic comments
dunno what happened to them lol
2022-07-24 13:39:13 -05:00
itsMapleLeaf 05c940ff52 destroying messages, placeholder for deactivate 2022-07-23 19:19:13 -05:00
itsMapleLeaf 4db32ddbbb async queue abstraction 2022-07-23 18:39:17 -05:00
itsMapleLeaf 02808b7550 split stuff up + handle immediate renders 2022-07-23 18:29:16 -05:00
itsMapleLeaf 1197d12a19 initial hacked-together draft 2022-07-23 17:46:54 -05:00
itsMapleLeaf 72f4a4afff changeset 2022-07-23 14:42:12 -05:00
itsMapleLeaf eed5715f1f update website with new remix typings 2022-07-23 14:24:12 -05:00
itsMapleLeaf e486da0881 migrate to cypress 10 2022-07-23 14:24:12 -05:00
itsMapleLeaf b275d9b330 update reconciler 2022-07-23 14:24:12 -05:00
itsMapleLeaf bab134d697 remove vite
was only used for viest config types, don't need it now
2022-07-23 14:24:12 -05:00
itsMapleLeaf df9bdfaf77 remove nanoid, use crypto.randomUUID()
removes a dependency, and resolves an ESM require error
2022-07-23 14:24:12 -05:00
itsMapleLeaf 35d7f0b33f fix linter warnings 2022-07-23 14:24:12 -05:00
itsMapleLeaf 4f9fb4310f upgrade dependencies 2022-07-23 14:24:12 -05:00
itsMapleLeaf 7b74628732 add link to template + other tweaks 2022-07-23 00:16:27 -05:00
itsMapleLeaf 7536bdee43 changeset 2022-07-22 23:17:03 -05:00
itsMapleLeaf ef8d915e3b add types field in exports to work with TS NodeNext 2022-07-22 23:15:57 -05:00
github-actions[bot] 3f078c91d2 Version Packages 2022-07-22 22:28:53 -05:00
itsMapleLeaf 8df7bc9baa back to the old script 2022-07-22 22:19:48 -05:00
itsMapleLeaf 52e587e70f add back changelog config 2022-07-22 22:12:53 -05:00
itsMapleLeaf 3152b1b79e add version to website 2022-07-22 22:11:43 -05:00
itsMapleLeaf d20afb094c fix pnpm-workspace.yaml to work with changesets 2022-07-22 22:10:25 -05:00
itsMapleLeaf 118f567e8d add publish config 2022-07-22 21:46:11 -05:00
itsMapleLeaf a447fefc7b fix changeset 2022-07-22 21:45:20 -05:00
itsMapleLeaf 9efc61d8eb update release workflow for pnpm 2022-07-22 21:44:48 -05:00
itsMapleLeaf aa65da59df changeset 2022-07-22 21:35:54 -05:00
itsMapleLeaf bc91080eca allow JSX for text in more places 2022-07-22 21:35:54 -05:00
itsMapleLeaf 9afe6fe0fa use changeset publish 2022-07-22 17:31:34 -05:00
itsMapleLeaf abc60528d5 set access public 2022-07-22 17:29:04 -05:00
itsMapleLeaf 413f88c7b8 remove ignore config 2022-07-22 17:28:45 -05:00
itsMapleLeaf b482f07788 ignore package by folder 2022-07-22 17:23:04 -05:00
itsMapleLeaf 3b191d274e always cancel in progress 2022-07-22 17:20:10 -05:00
itsMapleLeaf be5ec7c545 install pnpm in release workflow 2022-07-22 17:17:51 -05:00
itsMapleLeaf c93815b9f9 setup releasing in CI 2022-07-22 17:16:01 -05:00
itsMapleLeaf 1e527993e5 only publish reacord 2022-07-22 17:16:01 -05:00
itsMapleLeaf f4eae8da75 consume changeset 2022-07-22 17:16:01 -05:00
itsMapleLeaf 62505ca98c test changeset 2022-07-22 17:16:01 -05:00
itsMapleLeaf cc1bc0932f init changesets 2022-07-22 17:16:01 -05:00
itsMapleLeaf c08f5621ef space 2022-07-22 13:51:02 -05:00
itsMapleLeaf b6d2aac7a3 update release script 2022-07-22 13:51:02 -05:00
itsMapleLeaf 51ac0c89da add a manual tester in favor of playground 2022-07-22 13:51:02 -05:00
itsMapleLeaf f4985b1d87 run one workflow at a time 2022-07-22 13:51:02 -05:00
itsMapleLeaf 96affac979 release v0.4.0 2022-07-21 16:23:15 -05:00
Crawron 93b321dc36 clean imports 2022-07-21 16:20:14 -05:00
Crawron e313399a5a fix type guards 2022-07-21 16:20:14 -05:00
Crawron 90744ebe47 tweak and infer return type 2022-07-21 16:20:14 -05:00
Crawron 33bb2ee196 use enums instead of strings for component type 2022-07-21 16:20:14 -05:00
Crawron eb97b2d23d Add helper to convert button style to enum 2022-07-21 16:20:14 -05:00
Crawron 5aaaffbda9 Update playground for djs v14 2022-07-21 16:20:14 -05:00
Crawron 43029019f4 gitignore pnpm debug log 2022-07-21 16:20:14 -05:00
Crawron 8c481f18c6 Update Discord.js version 2022-07-21 16:20:14 -05:00
itsMapleLeaf 87ecb20f7a fix pnpm scripts & lock pnpm version 2022-07-09 15:13:44 -05:00
itsMapleLeaf 2324f3c89f release v0.3.7 2022-07-09 14:55:00 -05:00
itsMapleLeaf c35c32bddd fix cjs require 2022-07-09 14:54:27 -05:00
itsMapleLeaf 6eb36b44f3 move scripts to root for deployment 2022-07-07 12:20:00 -05:00
itsMapleLeaf a1fc0287fc remove engines config 2022-07-07 12:02:13 -05:00
itsMapleLeaf 02dd763e63 install node 16 for website 2022-07-07 12:01:06 -05:00
itsMapleLeaf a4024394e3 remove dockerfile 2022-07-07 11:56:13 -05:00
itsMapleLeaf c72096058a fix umami script 2022-07-07 11:49:14 -05:00
itsMapleLeaf 672fcd5bc4 release v0.3.6 2022-04-27 22:39:35 -05:00
itsMapleLeaf 25f34b3715 alias release script 2022-04-27 22:36:32 -05:00
itsMapleLeaf 8a7557f0eb lint/typecheck fixes 2022-04-25 19:58:47 -05:00
itsMapleLeaf fc3025baaf update configs 2022-04-25 14:52:04 -05:00
itsMapleLeaf 81f32794b4 fix import aliases 2022-04-24 19:52:21 -05:00
itsMapleLeaf dbf9640b16 lockfile 2022-04-24 19:41:53 -05:00
itsMapleLeaf a485ebaf74 improve pruneNullishValues + test 2022-04-24 16:05:00 -05:00
itsMapleLeaf 7ef5a7ac9d remove disableComponents function 2022-04-23 03:47:50 -05:00
itsMapleLeaf 6715756c2b fix deactivate overwriting edits 2022-04-23 03:44:50 -05:00
itsMapleLeaf 6851c5419a test improvements 2022-04-23 01:54:52 -05:00
itsMapleLeaf 1ba75492e5 reconciler fix 2022-04-23 00:44:31 -05:00
itsMapleLeaf aced338d72 use require.resolve for eslint config 2022-04-23 00:37:11 -05:00
itsMapleLeaf 512c0649d8 ignore workspace files 2022-04-23 00:37:00 -05:00
itsMapleLeaf 91b82ca41f fix vitest fn usage 2022-04-22 23:58:05 -05:00
itsMapleLeaf 752ccc080d update remix imports + format 2022-04-22 23:50:01 -05:00
itsMapleLeaf 3c2d3b4683 upgrades 2022-04-22 23:45:30 -05:00
itsMapleLeaf ad57674d6e 0.3.5 2022-04-22 23:27:43 -05:00
itsMapleLeaf 065bec9a37 Merge branches 'main' and 'main' of github.com:itsMapleLeaf/reacord 2022-04-22 23:23:35 -05:00
itsMapleLeaf d3ccafc6d5 update website links 2022-04-22 23:22:28 -05:00
Darius c71d70bbb4 remove domain redirect 2022-03-27 13:34:09 -05:00
itsMapleLeaf 5ba12af699 this should be call uncontrolled modal 2022-01-16 18:18:44 -06:00
itsMapleLeaf ff39ef753f add accessible text to header logo 2022-01-16 14:09:53 -06:00
itsMapleLeaf 2288c27e1e fix style preload 2022-01-16 13:46:08 -06:00
MapleLeaf c86648f44e deploy domain redirect to fly 2022-01-15 13:56:15 -06:00
MapleLeaf 0edf702b5f change umami url 2022-01-15 11:36:10 -06:00
MapleLeaf 05bda71ad6 fix action row example 2022-01-14 15:55:49 -06:00
MapleLeaf 0217fb8533 adjust prose style 2022-01-14 15:55:37 -06:00
MapleLeaf b59dcc0ae7 active nav link style 2022-01-13 17:05:46 -06:00
Darius 3efaef162b update npm badge to link to npm 2022-01-13 12:54:42 -06:00
Darius 4c2aafe185 add npm badge 2022-01-13 12:52:55 -06:00
152 changed files with 8351 additions and 6065 deletions
+8
View File
@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
+11
View File
@@ -0,0 +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": []
}
+25
View File
@@ -0,0 +1,25 @@
require("@rushstack/eslint-patch/modern-module-resolution")
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [require.resolve("@itsmapleleaf/configs/eslint")],
ignorePatterns: [
"**/node_modules/**",
"**/.cache/**",
"**/build/**",
"**/dist/**",
"**/coverage/**",
"**/public/**",
],
parserOptions: {
project: require.resolve("./tsconfig.base.json"),
},
overrides: [
{
files: ["packages/website/cypress/**"],
parserOptions: {
project: require.resolve("./packages/website/cypress/tsconfig.json"),
},
},
],
}
-26
View File
@@ -1,26 +0,0 @@
{
"extends": ["./node_modules/@itsmapleleaf/configs/eslint"],
"ignorePatterns": [
"**/node_modules/**",
"**/.cache/**",
"**/build/**",
"**/dist/**",
"**/coverage/**",
"**/public/**"
],
"parserOptions": {
"project": "./tsconfig.base.json"
},
"rules": {
"import/no-unused-modules": "off",
"unicorn/prevent-abbreviations": "off"
},
"overrides": [
{
"files": ["packages/website/cypress/**"],
"parserOptions": {
"project": "./packages/website/cypress/tsconfig.json"
}
}
]
}
-17
View File
@@ -1,17 +0,0 @@
name: deploy website
on:
push:
branches: [main]
paths:
- "packages/website/**"
- "reacord/library/**/*.{ts,tsx}"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: superfly/flyctl-actions@master
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
with:
args: "deploy"
+17 -8
View File
@@ -9,6 +9,11 @@ env:
TEST_BOT_TOKEN: ${{ secrets.TEST_BOT_TOKEN }} TEST_BOT_TOKEN: ${{ secrets.TEST_BOT_TOKEN }}
TEST_CHANNEL_ID: ${{ secrets.TEST_CHANNEL_ID }} TEST_CHANNEL_ID: ${{ secrets.TEST_CHANNEL_ID }}
TEST_GUILD_ID: ${{ secrets.TEST_GUILD_ID }} TEST_GUILD_ID: ${{ secrets.TEST_GUILD_ID }}
TEST_CATEGORY_ID: ${{ secrets.TEST_CATEGORY_ID }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
run-commands: run-commands:
@@ -16,26 +21,30 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
command: command:
# if these run in the same process, it dies, # if tests run in the same process, it dies,
# so we test them separate # so we test them separate
- name: test reacord - name: test
run: pnpm test -C packages/reacord run: pnpm test
- name: test website - name: test website
run: pnpm test -C packages/website # the cache doesn't include cypress install, need to do it manually here
run: pnpm -C packages/website exec cypress install && pnpm -C packages/website test
- name: build - name: build
run: pnpm build --recursive run: pnpm --recursive run build
- name: lint - name: lint
run: pnpm lint run: pnpm run lint
- name: typecheck - name: typecheck
run: pnpm typecheck --parallel run: pnpm --recursive run typecheck
name: ${{ matrix.command.name }} name: ${{ matrix.command.name }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: pnpm/action-setup@v2
with:
version: 7.8.0
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
# https://github.com/actions/setup-node#supported-version-syntax # https://github.com/actions/setup-node#supported-version-syntax
node-version: "16" node-version: "16"
- run: npm i -g pnpm cache: "pnpm"
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: ${{ matrix.command.run }} - run: ${{ matrix.command.run }}
+37
View File
@@ -0,0 +1,37 @@
# https://pnpm.io/using-changesets
name: release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: release
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v2
- name: setup node
uses: actions/setup-node@v2
with:
node-version: 16
- name: install pnpm
run: npm install -g pnpm
- name: install deps
run: pnpm install --frozen-lockfile
- name: changesets release
id: changesets
uses: changesets/action@v1
with:
publish: pnpm run release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+4
View File
@@ -3,4 +3,8 @@ node_modules
.vscode .vscode
coverage coverage
.env .env
*.code-workspace
.pnpm-debug.log
build
.cache
+1
View File
@@ -0,0 +1 @@
ignore-workspace-root-check = true
+1
View File
@@ -4,3 +4,4 @@ coverage
pnpm-lock.yaml pnpm-lock.yaml
build build
.cache .cache
packages/website/public/api
-15
View File
@@ -1,15 +0,0 @@
FROM node:lts-slim
ENV CYPRESS_INSTALL_BINARY=0
WORKDIR /app
COPY / ./
RUN ls -R
RUN npm install -g pnpm
RUN pnpm install --unsafe-perm --frozen-lockfile
RUN pnpm run build -C packages/website
ENV NODE_ENV=production
CMD [ "pnpm", "-C", "packages/website", "start" ]
+2 -2
View File
@@ -2,7 +2,7 @@
<img src="./packages/website/app/assets/banner.png" alt="Reacord: Create interactive Discord messages using React"> <img src="./packages/website/app/assets/banner.png" alt="Reacord: Create interactive Discord messages using React">
</center> </center>
## Installation ## Installation ∙ [![npm](https://img.shields.io/npm/v/reacord?color=blue&style=flat-square)](https://www.npmjs.com/package/reacord)
```console ```console
# npm # npm
@@ -18,7 +18,7 @@ pnpm add reacord react discord.js
## Get Started ## Get Started
[Visit the docs to get started.](https://reacord.fly.dev/guides/getting-started) [Visit the docs to get started.](https://reacord.mapleleaf.dev/guides/getting-started)
## Example ## Example
-40
View File
@@ -1,40 +0,0 @@
# fly.toml file generated for reacord on 2021-12-29T14:06:41-06:00
app = "reacord"
kill_signal = "SIGINT"
kill_timeout = 5
processes = []
[env]
PORT = 8080
[experimental]
allowed_public_ports = []
auto_rollback = true
[[services]]
http_checks = []
internal_port = 8080
processes = ["app"]
protocol = "tcp"
script_checks = []
[services.concurrency]
hard_limit = 25
soft_limit = 20
type = "connections"
[[services.ports]]
handlers = ["http"]
port = 80
[[services.ports]]
handlers = ["tls", "http"]
port = 443
[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"
restart_limit = 0
timeout = "2s"
+18 -16
View File
@@ -1,26 +1,28 @@
{ {
"name": "reacord-monorepo",
"private": true, "private": true,
"scripts": { "scripts": {
"lint": "eslint --ext js,ts,tsx .", "lint": "eslint --ext js,ts,tsx .",
"lint-fix": "pnpm lint -- --fix", "lint-fix": "pnpm lint -- --fix",
"format": "prettier --write ." "test": "vitest --coverage --no-watch",
}, "test-dev": "vitest --ui",
"dependencies": { "format": "prettier --write .",
"@itsmapleleaf/configs": "^1.1.2" "build": "pnpm -r run build",
"start": "pnpm -C packages/website run start",
"release": "pnpm -r run build && changeset publish"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.9.1", "@changesets/cli": "^2.24.2",
"@typescript-eslint/parser": "^5.9.1", "@itsmapleleaf/configs": "^1.1.5",
"eslint": "^8.6.0", "@rushstack/eslint-patch": "^1.1.4",
"eslint-config-prettier": "^8.3.0", "@types/eslint": "^8.4.5",
"eslint-import-resolver-typescript": "^2.5.0", "@vitest/ui": "^0.21.0",
"eslint-plugin-import": "^2.25.4", "c8": "^7.12.0",
"eslint-plugin-jsx-a11y": "^6.5.1", "eslint": "^8.21.0",
"eslint-plugin-react": "^7.28.0", "node": "^16.16.0",
"eslint-plugin-react-hooks": "^4.3.0", "prettier": "^2.7.1",
"eslint-plugin-unicorn": "^40.0.0", "typescript": "^4.7.4",
"prettier": "^2.5.1", "vitest": "^0.21.0"
"typescript": "^4.5.4"
}, },
"resolutions": { "resolutions": {
"esbuild": "latest" "esbuild": "latest"
+35
View File
@@ -0,0 +1,35 @@
export type AsyncCallback<T> = () => T
type QueueItem = {
callback: AsyncCallback<unknown>
resolve: (value: unknown) => void
reject: (error: unknown) => void
}
export class AsyncQueue {
private items: QueueItem[] = []
private running = false
append<T>(callback: AsyncCallback<T>): Promise<Awaited<T>> {
return new Promise((resolve, reject) => {
this.items.push({ callback, resolve: resolve as any, reject })
void this.run()
})
}
private async run() {
if (this.running) return
this.running = true
let item
while ((item = this.items.shift())) {
try {
item.resolve(await item.callback())
} catch (error) {
item.reject(error)
}
}
this.running = false
}
}
@@ -0,0 +1,21 @@
export function generatePropCombinations<P>(values: {
[K in keyof P]: ReadonlyArray<P[K]>
}) {
return generatePropCombinationsRecursive(values) as P[]
}
function generatePropCombinationsRecursive(
value: Record<string, readonly unknown[]>,
): Array<Record<string, unknown>> {
const [key] = Object.keys(value)
if (!key) return [{}]
const { [key]: values = [], ...otherValues } = value
const result: Array<Record<string, unknown>> = []
for (const value of values) {
for (const otherValue of generatePropCombinationsRecursive(otherValues)) {
result.push({ [key]: value, ...otherValue })
}
}
return result
}
@@ -1,6 +1,5 @@
import { inspect } from "node:util" import { inspect } from "node:util"
// eslint-disable-next-line import/no-unused-modules
export function logPretty(value: unknown) { export function logPretty(value: unknown) {
console.info( console.info(
inspect(value, { inspect(value, {
@@ -1,4 +1,3 @@
// eslint-disable-next-line import/no-unused-modules
export function omit<Subject extends object, Key extends PropertyKey>( export function omit<Subject extends object, Key extends PropertyKey>(
subject: Subject, subject: Subject,
keys: Key[], keys: Key[],
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@reacord/helpers",
"type": "module",
"private": true,
"dependencies": {
"@types/lodash-es": "^4.17.6",
"@types/node": "*",
"lodash-es": "^4.17.21",
"type-fest": "^2.18.0"
}
}
@@ -1,6 +1,5 @@
import type { LoosePick, UnknownRecord } from "./types" import type { LoosePick, UnknownRecord } from "./types"
// eslint-disable-next-line import/no-unused-modules
export function pick<T, K extends keyof T | PropertyKey>( export function pick<T, K extends keyof T | PropertyKey>(
object: T, object: T,
keys: K[], keys: K[],
@@ -0,0 +1,35 @@
import { expect, test } from "vitest"
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
}
}
const input: InputType = {
a: "a",
// eslint-disable-next-line unicorn/no-null
b: null,
c: undefined,
d: {
a: "a",
b: undefined,
},
}
const output: PruneNullishValues<InputType> = {
a: "a",
d: {
a: "a",
},
}
expect(pruneNullishValues(input)).toEqual(output)
})
+42
View File
@@ -0,0 +1,42 @@
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 any
}
const result: any = {}
for (const [key, value] of Object.entries(input)) {
if (value != undefined) {
result[key] = pruneNullishValues(value)
}
}
return result
}
export type PruneNullishValues<Input> = Input extends object
? 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]
}
type KeysWithNullishValues<Input> = NonNullable<
Values<{
[Key in keyof Input]: null extends Input[Key]
? Key
: undefined extends Input[Key]
? Key
: never
}>
>
type Values<Input> = Input[keyof Input]
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.base.json"
}
@@ -1,4 +1,3 @@
/* eslint-disable import/no-unused-modules */
export type MaybePromise<T> = T | Promise<T> export type MaybePromise<T> = T | Promise<T>
export type ValueOf<Type> = Type extends ReadonlyArray<infer Value> export type ValueOf<Type> = Type extends ReadonlyArray<infer Value>
+21
View File
@@ -0,0 +1,21 @@
import { setTimeout } from "node:timers/promises"
const maxTime = 1000
export async function waitFor<Result>(
predicate: () => Result,
): Promise<Awaited<Result>> {
const startTime = Date.now()
let lastError: unknown
while (Date.now() - startTime < maxTime) {
try {
return await predicate()
} catch (error) {
lastError = error
await setTimeout(50)
}
}
throw lastError ?? new Error("Timeout")
}
@@ -1,6 +1,5 @@
import { inspect } from "node:util" import { inspect } from "node:util"
// eslint-disable-next-line import/no-unused-modules
export function withLoggedMethodCalls<T extends object>(value: T) { export function withLoggedMethodCalls<T extends object>(value: T) {
return new Proxy(value as Record<string | symbol, unknown>, { return new Proxy(value as Record<string | symbol, unknown>, {
get(target, property) { get(target, property) {
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@reacord/playground",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/main.tsx"
},
"dependencies": {
"@reacord/helpers": "workspace:*",
"discord.js": "^14.1.2",
"dotenv": "^16.0.1",
"ora": "^6.1.2",
"react": "^18.2.0"
},
"devDependencies": {
"@types/node": "*",
"@types/react": "^18.0.16",
"tsx": "^3.8.0",
"typescript": "^4.7.4"
}
}
+54
View File
@@ -0,0 +1,54 @@
import { raise } from "@reacord/helpers/raise"
import { Client, GatewayIntentBits } from "discord.js"
import * as dotenv from "dotenv"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
import { oraPromise } from "ora"
import React from "react"
import { Button, ReacordClient } from "../../reacord/src/main"
dotenv.config({
path: join(fileURLToPath(import.meta.url), "../../../../.env"),
override: true,
})
const token = process.env.TEST_BOT_TOKEN ?? raise("TEST_BOT_TOKEN not defined")
const client = new Client({ intents: [GatewayIntentBits.Guilds] })
const reacord = new ReacordClient({ token })
client.once("ready", async (client) => {
try {
await oraPromise(
client.application.commands.create({
name: "counter",
description: "counts things",
}),
"Registering commands",
)
} catch (error) {
console.error("Failed to register commands:", error)
}
})
client.on("interactionCreate", async (interaction) => {
if (
interaction.isChatInputCommand() &&
interaction.commandName === "counter"
) {
reacord.reply(interaction, <Counter />)
// reacord.reply(interaction, "test3").render("test4")
}
})
await oraPromise(client.login(token), "Logging in")
function Counter() {
const [count, setCount] = React.useState(0)
return (
<>
count: {count}
<Button label="+" onClick={() => setCount(count + 1)} />
</>
)
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.base.json"
}
+15
View File
@@ -0,0 +1,15 @@
# reacord
## 0.5.1
### Patch Changes
- 72f4a4a: upgrade dependencies and remove some unneeded
- 7536bde: add types in exports to work with TS nodenext
- e335165: fix links
## 0.5.0
### Minor Changes
- aa65da5: allow JSX in more places
@@ -1,27 +0,0 @@
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 any
}
const result: any = {}
for (const [key, value] of Object.entries(input)) {
if (value != undefined) {
result[key] = pruneNullishValues(value)
}
}
return result
}
type PruneNullishValues<Input> = Input extends ReadonlyArray<infer Value>
? ReadonlyArray<NonNullable<Value>>
: Input extends object
? {
[Key in keyof Input]: NonNullable<Input[Key]>
}
: Input
@@ -1,113 +0,0 @@
import type { ReactNode } from "react"
import type { ReacordInstance } from "./instance"
/**
* @category Component Event
*/
export type ComponentEvent = {
/**
* The message associated with this event.
* For example: with a button click,
* this is the message that the button is on.
* @see https://discord.com/developers/docs/resources/channel#message-object
*/
message: MessageInfo
/**
* The channel that this event occurred in.
* @see https://discord.com/developers/docs/resources/channel#channel-object
*/
channel: ChannelInfo
/**
* The user that triggered this event.
* @see https://discord.com/developers/docs/resources/user#user-object
*/
user: UserInfo
/**
* The guild that this event occurred in.
* @see https://discord.com/developers/docs/resources/guild#guild-object
*/
guild?: GuildInfo
/**
* Create a new reply to this event.
*/
reply(content?: ReactNode): ReacordInstance
/**
* Create an ephemeral reply to this event,
* shown only to the user who triggered it.
*/
ephemeralReply(content?: ReactNode): ReacordInstance
}
/**
* @category Component Event
*/
export type ChannelInfo = {
id: string
name?: string
topic?: string
nsfw?: boolean
lastMessageId?: string
ownerId?: string
parentId?: string
rateLimitPerUser?: number
}
/**
* @category Component Event
*/
export type MessageInfo = {
id: string
channelId: string
authorId: UserInfo
member?: GuildMemberInfo
content: string
timestamp: string
editedTimestamp?: string
tts: boolean
mentionEveryone: boolean
/** The IDs of mentioned users */
mentions: string[]
}
/**
* @category Component Event
*/
export type GuildInfo = {
id: string
name: string
member: GuildMemberInfo
}
/**
* @category Component Event
*/
export type GuildMemberInfo = {
id: string
nick?: string
displayName: string
avatarUrl?: string
displayAvatarUrl: string
roles: string[]
color: number
joinedAt?: string
premiumSince?: string
pending?: boolean
communicationDisabledUntil?: string
}
/**
* @category Component Event
*/
export type UserInfo = {
id: string
username: string
discriminator: string
tag: string
avatarUrl: string
accentColor?: number
}
@@ -1,65 +0,0 @@
import { nanoid } from "nanoid"
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import type { ComponentInteraction } from "../../internal/interaction"
import type { MessageOptions } from "../../internal/message"
import { getNextActionRow } from "../../internal/message"
import { Node } from "../../internal/node.js"
import type { ComponentEvent } from "../component-event"
import type { ButtonSharedProps } from "./button-shared-props"
/**
* @category Button
*/
export type ButtonProps = ButtonSharedProps & {
/**
* The style determines the color of the button and signals intent.
* @see https://discord.com/developers/docs/interactions/message-components#button-object-button-styles
*/
style?: "primary" | "secondary" | "success" | "danger"
/**
* Happens when a user clicks the button.
*/
onClick: (event: ButtonClickEvent) => void
}
/**
* @category Button
*/
export type ButtonClickEvent = ComponentEvent
/**
* @category Button
*/
export function Button(props: ButtonProps) {
return (
<ReacordElement props={props} createNode={() => new ButtonNode(props)} />
)
}
class ButtonNode extends Node<ButtonProps> {
private customId = nanoid()
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.props.label,
})
}
override handleComponentInteraction(interaction: ComponentInteraction) {
if (
interaction.type === "button" &&
interaction.customId === this.customId
) {
this.props.onClick(interaction.event)
return true
}
return false
}
}
@@ -1,36 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedAuthorProps = {
name?: string
children?: string
url?: string
iconUrl?: string
}
/**
* @category Embed
*/
export function EmbedAuthor(props: EmbedAuthorProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedAuthorNode(props)}
/>
)
}
class EmbedAuthorNode extends EmbedChildNode<EmbedAuthorProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.author = {
name: this.props.name ?? this.props.children ?? "",
url: this.props.url,
icon_url: this.props.iconUrl,
}
}
}
@@ -1,6 +0,0 @@
import { Node } from "../../internal/node.js"
import type { EmbedOptions } from "./embed-options"
export abstract class EmbedChildNode<Props> extends Node<Props> {
abstract modifyEmbedOptions(options: EmbedOptions): void
}
@@ -1,37 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedFieldProps = {
name: string
value?: string
inline?: boolean
children?: string
}
/**
* @category Embed
*/
export function EmbedField(props: EmbedFieldProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedFieldNode(props)}
/>
)
}
class EmbedFieldNode extends EmbedChildNode<EmbedFieldProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.fields ??= []
options.fields.push({
name: this.props.name,
value: this.props.value ?? this.props.children ?? "",
inline: this.props.inline,
})
}
}
@@ -1,38 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedFooterProps = {
text?: string
children?: string
iconUrl?: string
timestamp?: string | number | Date
}
/**
* @category Embed
*/
export function EmbedFooter(props: EmbedFooterProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedFooterNode(props)}
/>
)
}
class EmbedFooterNode extends EmbedChildNode<EmbedFooterProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.footer = {
text: this.props.text ?? this.props.children ?? "",
icon_url: this.props.iconUrl,
}
options.timestamp = this.props.timestamp
? new Date(this.props.timestamp).toISOString()
: undefined
}
}
@@ -1,29 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedImageProps = {
url: string
}
/**
* @category Embed
*/
export function EmbedImage(props: EmbedImageProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedImageNode(props)}
/>
)
}
class EmbedImageNode extends EmbedChildNode<EmbedImageProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.image = { url: this.props.url }
}
}
@@ -1,8 +0,0 @@
import type { Except, SnakeCasedPropertiesDeep } from "type-fest"
import type { EmbedProps } from "./embed"
export type EmbedOptions = SnakeCasedPropertiesDeep<
Except<EmbedProps, "timestamp" | "children"> & {
timestamp?: string
}
>
@@ -1,29 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedThumbnailProps = {
url: string
}
/**
* @category Embed
*/
export function EmbedThumbnail(props: EmbedThumbnailProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedThumbnailNode(props)}
/>
)
}
class EmbedThumbnailNode extends EmbedChildNode<EmbedThumbnailProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.thumbnail = { url: this.props.url }
}
}
@@ -1,31 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedTitleProps = {
children: string
url?: string
}
/**
* @category Embed
*/
export function EmbedTitle(props: EmbedTitleProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedTitleNode(props)}
/>
)
}
class EmbedTitleNode extends EmbedChildNode<EmbedTitleProps> {
override modifyEmbedOptions(options: EmbedOptions): void {
options.title = this.props.children
options.url = this.props.url
}
}
@@ -1,62 +0,0 @@
import React from "react"
import { snakeCaseDeep } from "../../../helpers/convert-object-property-case"
import { omit } from "../../../helpers/omit"
import { ReacordElement } from "../../internal/element.js"
import type { MessageOptions } from "../../internal/message"
import { Node } from "../../internal/node.js"
import { TextNode } from "../../internal/text-node"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
* @see https://discord.com/developers/docs/resources/channel#embed-object
*/
export type EmbedProps = {
title?: string
description?: string
url?: string
color?: number
fields?: Array<{ name: string; value: string; inline?: boolean }>
author?: { name: string; url?: string; iconUrl?: string }
thumbnail?: { url: string }
image?: { url: string }
video?: { url: string }
footer?: { text: string; iconUrl?: string }
timestamp?: string | number | Date
children?: React.ReactNode
}
/**
* @category Embed
* @see https://discord.com/developers/docs/resources/channel#embed-object
*/
export function Embed(props: EmbedProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedNode(props)}>
{props.children}
</ReacordElement>
)
}
class EmbedNode extends Node<EmbedProps> {
override modifyMessageOptions(options: MessageOptions): void {
const embed: EmbedOptions = {
...snakeCaseDeep(omit(this.props, ["children", "timestamp"])),
timestamp: this.props.timestamp
? new Date(this.props.timestamp).toISOString()
: undefined,
}
for (const child of this.children) {
if (child instanceof EmbedChildNode) {
child.modifyEmbedOptions(embed)
}
if (child instanceof TextNode) {
embed.description = (embed.description || "") + child.props
}
}
options.embeds.push(embed)
}
}
@@ -1,35 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import type { MessageOptions } from "../../internal/message"
import { getNextActionRow } from "../../internal/message"
import { Node } from "../../internal/node.js"
import type { ButtonSharedProps } from "./button-shared-props"
/**
* @category Link
*/
export type LinkProps = ButtonSharedProps & {
/** The URL the link should lead to */
url: string
/** The link text */
children?: string
}
/**
* @category Link
*/
export function Link(props: LinkProps) {
return <ReacordElement props={props} createNode={() => new LinkNode(props)} />
}
class LinkNode extends Node<LinkProps> {
override modifyMessageOptions(options: MessageOptions): void {
getNextActionRow(options).push({
type: "link",
disabled: this.props.disabled,
emoji: this.props.emoji,
label: this.props.label || this.props.children,
url: this.props.url,
})
}
}
@@ -1,14 +0,0 @@
import type { MessageSelectOptionOptions } from "../../internal/message"
import { Node } from "../../internal/node"
import type { OptionProps } from "./option"
export class OptionNode extends Node<OptionProps> {
get options(): MessageSelectOptionOptions {
return {
label: this.props.children || this.props.label || this.props.value,
value: this.props.value,
description: this.props.description,
emoji: this.props.emoji,
}
}
}
@@ -1,38 +0,0 @@
import React from "react"
import { ReacordElement } from "../../internal/element"
import { 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?: string
/** The text shown to the user */
children?: string
/** Description for the option, shown to the user */
description?: 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
*/
export function Option(props: OptionProps) {
return (
<ReacordElement props={props} createNode={() => new OptionNode(props)} />
)
}
-19
View File
@@ -1,19 +0,0 @@
import type { ReactNode } from "react"
/**
* Represents an interactive message, which can later be replaced or deleted.
* @category Core
*/
export type ReacordInstance = {
/** Render some JSX to this instance (edits the message) */
render: (content: ReactNode) => void
/** Remove this message */
destroy: () => void
/**
* Same as destroy, but keeps the message and disables the components on it.
* This prevents it from listening to user interactions.
*/
deactivate: () => void
}
@@ -1,381 +0,0 @@
/* eslint-disable class-methods-use-this */
import * as Discord from "discord.js"
import type { ReactNode } from "react"
import type { Except } from "type-fest"
import { pick } from "../../helpers/pick"
import { pruneNullishValues } from "../../helpers/prune-nullish-values"
import { raise } from "../../helpers/raise"
import { toUpper } from "../../helpers/to-upper"
import type { ComponentInteraction } from "../internal/interaction"
import type { Message, MessageOptions } from "../internal/message"
import { ChannelMessageRenderer } from "../internal/renderers/channel-message-renderer"
import { InteractionReplyRenderer } from "../internal/renderers/interaction-reply-renderer"
import type {
ChannelInfo,
GuildInfo,
GuildMemberInfo,
MessageInfo,
UserInfo,
} from "./component-event"
import type { ReacordInstance } from "./instance"
import type { ReacordConfig } from "./reacord"
import { Reacord } from "./reacord"
/**
* The Reacord adapter for Discord.js.
* @category Core
*/
export class ReacordDiscordJs extends Reacord {
constructor(private client: Discord.Client, config: ReacordConfig = {}) {
super(config)
client.on("interactionCreate", (interaction) => {
if (interaction.isMessageComponent()) {
this.handleComponentInteraction(
this.createReacordComponentInteraction(interaction),
)
}
})
}
/**
* Sends a message to a channel.
* @see https://reacord.fly.dev/guides/sending-messages
*/
override send(
channelId: string,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createChannelRenderer(channelId),
initialContent,
)
}
/**
* Sends a message as a reply to a command interaction.
* @see https://reacord.fly.dev/guides/sending-messages
*/
override reply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createInteractionReplyRenderer(interaction),
initialContent,
)
}
/**
* Sends an ephemeral message as a reply to a command interaction.
* @see https://reacord.fly.dev/guides/sending-messages
*/
override ephemeralReply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createEphemeralInteractionReplyRenderer(interaction),
initialContent,
)
}
private createChannelRenderer(channelId: string) {
return new ChannelMessageRenderer({
send: async (options) => {
const channel =
this.client.channels.cache.get(channelId) ??
(await this.client.channels.fetch(channelId)) ??
raise(`Channel ${channelId} not found`)
if (!channel.isText()) {
raise(`Channel ${channelId} is not a text channel`)
}
const message = await channel.send(getDiscordMessageOptions(options))
return createReacordMessage(message)
},
})
}
private createInteractionReplyRenderer(
interaction:
| Discord.CommandInteraction
| Discord.MessageComponentInteraction,
) {
return new InteractionReplyRenderer({
type: "command",
id: interaction.id,
reply: async (options) => {
const message = await interaction.reply({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
followUp: async (options) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
})
}
private createEphemeralInteractionReplyRenderer(
interaction:
| Discord.CommandInteraction
| Discord.MessageComponentInteraction,
) {
return new InteractionReplyRenderer({
type: "command",
id: interaction.id,
reply: async (options) => {
await interaction.reply({
...getDiscordMessageOptions(options),
ephemeral: true,
})
return createEphemeralReacordMessage()
},
followUp: async (options) => {
await interaction.followUp({
...getDiscordMessageOptions(options),
ephemeral: true,
})
return createEphemeralReacordMessage()
},
})
}
private createReacordComponentInteraction(
interaction: Discord.MessageComponentInteraction,
): ComponentInteraction {
// todo please dear god clean this up
const channel: ChannelInfo = interaction.channel
? {
...pruneNullishValues(
pick(interaction.channel, [
"topic",
"nsfw",
"lastMessageId",
"ownerId",
"parentId",
"rateLimitPerUser",
]),
),
id: interaction.channelId,
}
: raise("Non-channel interactions are not supported")
const message: MessageInfo =
interaction.message instanceof Discord.Message
? {
...pick(interaction.message, [
"id",
"channelId",
"authorId",
"content",
"tts",
"mentionEveryone",
]),
timestamp: new Date(
interaction.message.createdTimestamp,
).toISOString(),
editedTimestamp: interaction.message.editedTimestamp
? new Date(interaction.message.editedTimestamp).toISOString()
: undefined,
mentions: interaction.message.mentions.users.map((u) => u.id),
}
: raise("Message not found")
const member: GuildMemberInfo | undefined =
interaction.member instanceof Discord.GuildMember
? {
...pruneNullishValues(
pick(interaction.member, [
"id",
"nick",
"displayName",
"avatarUrl",
"displayAvatarUrl",
"color",
"pending",
]),
),
displayName: interaction.member.displayName,
roles: [...interaction.member.roles.cache.map((role) => role.id)],
joinedAt: interaction.member.joinedAt?.toISOString(),
premiumSince: interaction.member.premiumSince?.toISOString(),
communicationDisabledUntil:
interaction.member.communicationDisabledUntil?.toISOString(),
}
: undefined
const guild: GuildInfo | undefined = interaction.guild
? {
...pruneNullishValues(pick(interaction.guild, ["id", "name"])),
member: member ?? raise("unexpected: member is undefined"),
}
: undefined
const user: UserInfo = {
...pruneNullishValues(
pick(interaction.user, ["id", "username", "discriminator", "tag"]),
),
avatarUrl: interaction.user.avatarURL()!,
accentColor: interaction.user.accentColor ?? undefined,
}
const baseProps: Except<ComponentInteraction, "type"> = {
id: interaction.id,
customId: interaction.customId,
update: async (options: MessageOptions) => {
await interaction.update(getDiscordMessageOptions(options))
},
deferUpdate: async () => {
if (interaction.replied || interaction.deferred) return
await interaction.deferUpdate()
},
reply: async (options) => {
const message = await interaction.reply({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
followUp: async (options) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
event: {
channel,
message,
user,
guild,
reply: (content?: ReactNode) =>
this.createInstance(
this.createInteractionReplyRenderer(interaction),
content,
),
ephemeralReply: (content: ReactNode) =>
this.createInstance(
this.createEphemeralInteractionReplyRenderer(interaction),
content,
),
},
}
if (interaction.isButton()) {
return {
...baseProps,
type: "button",
}
}
if (interaction.isSelectMenu()) {
return {
...baseProps,
type: "select",
event: {
...baseProps.event,
values: interaction.values,
},
}
}
raise(`Unsupported component interaction type: ${interaction.type}`)
}
}
function createReacordMessage(message: Discord.Message): Message {
return {
edit: async (options) => {
await message.edit(getDiscordMessageOptions(options))
},
disableComponents: async () => {
for (const actionRow of message.components) {
for (const component of actionRow.components) {
component.setDisabled(true)
}
}
await message.edit({
components: message.components,
})
},
delete: async () => {
await message.delete()
},
}
}
function createEphemeralReacordMessage(): Message {
return {
edit: () => {
console.warn("Ephemeral messages can't be edited")
return Promise.resolve()
},
disableComponents: () => {
console.warn("Ephemeral messages can't be edited")
return Promise.resolve()
},
delete: () => {
console.warn("Ephemeral messages can't be deleted")
return Promise.resolve()
},
}
}
// TODO: this could be a part of the core library,
// and also handle some edge cases, e.g. empty messages
function getDiscordMessageOptions(
reacordOptions: MessageOptions,
): Discord.MessageOptions {
const options: Discord.MessageOptions = {
// eslint-disable-next-line unicorn/no-null
content: reacordOptions.content || null,
embeds: reacordOptions.embeds,
components: reacordOptions.actionRows.map((row) => ({
type: "ACTION_ROW",
components: row.map(
(component): Discord.MessageActionRowComponentOptions => {
if (component.type === "button") {
return {
type: "BUTTON",
customId: component.customId,
label: component.label ?? "",
style: toUpper(component.style ?? "secondary"),
disabled: component.disabled,
emoji: component.emoji,
}
}
if (component.type === "select") {
return {
...component,
type: "SELECT_MENU",
options: component.options.map((option) => ({
...option,
default: component.values?.includes(option.value),
})),
}
}
raise(`Unsupported component type: ${component.type}`)
},
),
})),
}
if (!options.content && !options.embeds?.length) {
options.content = "_ _"
}
return options
}
-79
View File
@@ -1,79 +0,0 @@
import type { ReactNode } from "react"
import React from "react"
import type { ComponentInteraction } from "../internal/interaction"
import { reconciler } from "../internal/reconciler.js"
import type { Renderer } from "../internal/renderers/renderer"
import type { ReacordInstance } from "./instance"
import { InstanceProvider } from "./instance-context"
/**
* @category Core
*/
export type ReacordConfig = {
/**
* The max number of active instances.
* When this limit is exceeded, the oldest instances will be disabled.
*/
maxInstances?: number
}
/**
* The main Reacord class that other Reacord adapters should extend.
* Only use this directly if you're making [a custom adapter](/guides/custom-adapters).
*/
export abstract class Reacord {
private renderers: Renderer[] = []
constructor(private readonly config: ReacordConfig = {}) {}
abstract send(...args: unknown[]): ReacordInstance
abstract reply(...args: unknown[]): ReacordInstance
abstract ephemeralReply(...args: unknown[]): ReacordInstance
protected handleComponentInteraction(interaction: ComponentInteraction) {
for (const renderer of this.renderers) {
if (renderer.handleComponentInteraction(interaction)) return
}
}
private get maxInstances() {
return this.config.maxInstances ?? 50
}
protected createInstance(renderer: Renderer, initialContent?: ReactNode) {
if (this.renderers.length > this.maxInstances) {
this.deactivate(this.renderers[0]!)
}
this.renderers.push(renderer)
const container = reconciler.createContainer(renderer, 0, false, {})
const instance: ReacordInstance = {
render: (content: ReactNode) => {
reconciler.updateContainer(
<InstanceProvider value={instance}>{content}</InstanceProvider>,
container,
)
},
deactivate: () => {
this.deactivate(renderer)
},
destroy: () => {
this.renderers = this.renderers.filter((it) => it !== renderer)
renderer.destroy()
},
}
if (initialContent !== undefined) {
instance.render(initialContent)
}
return instance
}
private deactivate(renderer: Renderer) {
this.renderers = this.renderers.filter((it) => it !== renderer)
renderer.deactivate()
}
}
@@ -1,5 +0,0 @@
import type { Message, MessageOptions } from "./message"
export type Channel = {
send(message: MessageOptions): Promise<Message>
}
@@ -1,27 +0,0 @@
export class Container<T> {
private items: T[] = []
add(...items: T[]) {
this.items.push(...items)
}
addBefore(item: T, before: T) {
let index = this.items.indexOf(before)
if (index === -1) {
index = this.items.length
}
this.items.splice(index, 0, item)
}
remove(toRemove: T) {
this.items = this.items.filter((item) => item !== toRemove)
}
clear() {
this.items = []
}
[Symbol.iterator]() {
return this.items[Symbol.iterator]()
}
}
@@ -1,35 +0,0 @@
import type { ComponentEvent } from "../core/component-event"
import type { ButtonClickEvent, SelectChangeEvent } from "../main"
import type { Message, MessageOptions } from "./message"
export type Interaction = CommandInteraction | ComponentInteraction
export type ComponentInteraction = ButtonInteraction | SelectInteraction
export type CommandInteraction = BaseInteraction<"command">
export type ButtonInteraction = BaseComponentInteraction<
"button",
ButtonClickEvent
>
export type SelectInteraction = BaseComponentInteraction<
"select",
SelectChangeEvent
>
export type BaseInteraction<Type extends string> = {
type: Type
id: string
reply(messageOptions: MessageOptions): Promise<Message>
followUp(messageOptions: MessageOptions): Promise<Message>
}
export type BaseComponentInteraction<
Type extends string,
Event extends ComponentEvent,
> = BaseInteraction<Type> & {
event: Event
customId: string
update(options: MessageOptions): Promise<void>
deferUpdate(): Promise<void>
}
@@ -1,24 +0,0 @@
export class LimitedCollection<T> {
private items: T[] = []
constructor(private readonly size: number) {}
add(item: T) {
if (this.items.length >= this.size) {
this.items.shift()
}
this.items.push(item)
}
has(item: T) {
return this.items.includes(item)
}
values(): readonly T[] {
return this.items
}
[Symbol.iterator]() {
return this.items[Symbol.iterator]()
}
}
@@ -1,66 +0,0 @@
import type { Except } from "type-fest"
import { last } from "../../helpers/last"
import type { EmbedOptions } from "../core/components/embed-options"
import type { SelectProps } from "../core/components/select"
export type MessageOptions = {
content: string
embeds: EmbedOptions[]
actionRows: ActionRow[]
}
export type ActionRow = ActionRowItem[]
export type ActionRowItem =
| MessageButtonOptions
| MessageLinkOptions
| MessageSelectOptions
export type MessageButtonOptions = {
type: "button"
customId: string
label?: string
style?: "primary" | "secondary" | "success" | "danger"
disabled?: boolean
emoji?: string
}
export type MessageLinkOptions = {
type: "link"
url: string
label?: string
emoji?: string
disabled?: boolean
}
export type MessageSelectOptions = Except<SelectProps, "children" | "value"> & {
type: "select"
customId: string
options: MessageSelectOptionOptions[]
}
export type MessageSelectOptionOptions = {
label: string
value: string
description?: string
emoji?: string
}
export type Message = {
edit(options: MessageOptions): Promise<void>
delete(): Promise<void>
disableComponents(): 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
}
-16
View File
@@ -1,16 +0,0 @@
/* eslint-disable class-methods-use-this */
import { Container } from "./container.js"
import type { ComponentInteraction } from "./interaction"
import type { MessageOptions } from "./message"
export abstract class Node<Props> {
readonly children = new Container<Node<unknown>>()
constructor(public props: Props) {}
modifyMessageOptions(options: MessageOptions) {}
handleComponentInteraction(interaction: ComponentInteraction): boolean {
return false
}
}
@@ -1,13 +0,0 @@
import type { Channel } from "../channel"
import type { Message, MessageOptions } from "../message"
import { Renderer } from "./renderer"
export class ChannelMessageRenderer extends Renderer {
constructor(private channel: Channel) {
super()
}
protected createMessage(options: MessageOptions): Promise<Message> {
return this.channel.send(options)
}
}
@@ -1,22 +0,0 @@
import type { Interaction } from "../interaction"
import type { Message, MessageOptions } from "../message"
import { Renderer } from "./renderer"
// keep track of interaction ids which have replies,
// so we know whether to call reply() or followUp()
const repliedInteractionIds = new Set<string>()
export class InteractionReplyRenderer extends Renderer {
constructor(private interaction: Interaction) {
super()
}
protected createMessage(options: MessageOptions): Promise<Message> {
if (repliedInteractionIds.has(this.interaction.id)) {
return this.interaction.followUp(options)
}
repliedInteractionIds.add(this.interaction.id)
return this.interaction.reply(options)
}
}
@@ -1,109 +0,0 @@
import { Subject } from "rxjs"
import { concatMap } from "rxjs/operators"
import { Container } from "../container.js"
import type { ComponentInteraction } from "../interaction"
import type { Message, MessageOptions } from "../message"
import type { Node } from "../node.js"
type UpdatePayload =
| { action: "update" | "deactivate"; options: MessageOptions }
| { action: "deferUpdate"; interaction: ComponentInteraction }
| { action: "destroy" }
export abstract class Renderer {
readonly nodes = new Container<Node<unknown>>()
private componentInteraction?: ComponentInteraction
private message?: Message
private active = true
private updates = new Subject<UpdatePayload>()
private updateSubscription = this.updates
.pipe(concatMap((payload) => this.updateMessage(payload)))
.subscribe({ error: console.error })
render() {
if (!this.active) {
console.warn("Attempted to update a deactivated message")
return
}
this.updates.next({
options: this.getMessageOptions(),
action: "update",
})
}
deactivate() {
this.active = false
this.updates.next({
options: this.getMessageOptions(),
action: "deactivate",
})
}
destroy() {
this.active = false
this.updates.next({ action: "destroy" })
}
handleComponentInteraction(interaction: ComponentInteraction) {
this.componentInteraction = interaction
setTimeout(() => {
this.updates.next({ action: "deferUpdate", interaction })
}, 500)
for (const node of this.nodes) {
if (node.handleComponentInteraction(interaction)) {
return true
}
}
}
protected abstract createMessage(options: MessageOptions): Promise<Message>
private getMessageOptions(): MessageOptions {
const options: MessageOptions = {
content: "",
embeds: [],
actionRows: [],
}
for (const node of this.nodes) {
node.modifyMessageOptions(options)
}
return options
}
private async updateMessage(payload: UpdatePayload) {
if (payload.action === "destroy") {
this.updateSubscription.unsubscribe()
await this.message?.delete()
return
}
if (payload.action === "deactivate") {
this.updateSubscription.unsubscribe()
await this.message?.disableComponents()
return
}
if (payload.action === "deferUpdate") {
await payload.interaction.deferUpdate()
return
}
if (this.componentInteraction) {
const promise = this.componentInteraction.update(payload.options)
this.componentInteraction = undefined
await promise
return
}
if (this.message) {
await this.message.edit(payload.options)
return
}
this.message = await this.createMessage(payload.options)
}
}
@@ -1,8 +0,0 @@
import type { MessageOptions } from "./message"
import { Node } from "./node.js"
export class TextNode extends Node<string> {
override modifyMessageOptions(options: MessageOptions) {
options.content = options.content + this.props
}
}
@@ -1,20 +0,0 @@
export class Timeout {
private timeoutId?: NodeJS.Timeout
constructor(
private readonly time: number,
private readonly callback: () => void,
) {}
run() {
this.cancel()
this.timeoutId = setTimeout(this.callback, this.time)
}
cancel() {
if (this.timeoutId) {
clearTimeout(this.timeoutId)
this.timeoutId = undefined
}
}
}
-18
View File
@@ -1,18 +0,0 @@
export * from "./core/component-event"
export * from "./core/components/action-row"
export * from "./core/components/button"
export * from "./core/components/button-shared-props"
export * from "./core/components/embed"
export * from "./core/components/embed-author"
export * from "./core/components/embed-field"
export * from "./core/components/embed-footer"
export * from "./core/components/embed-image"
export * from "./core/components/embed-thumbnail"
export * from "./core/components/embed-title"
export * from "./core/components/link"
export * from "./core/components/option"
export * from "./core/components/select"
export * from "./core/instance"
export { useInstance } from "./core/instance-context"
export * from "./core/reacord"
export * from "./core/reacord-discord-js"
+45 -32
View File
@@ -2,9 +2,9 @@
"name": "reacord", "name": "reacord",
"type": "module", "type": "module",
"description": "Create interactive Discord messages using React.", "description": "Create interactive Discord messages using React.",
"version": "0.3.4", "version": "0.5.1",
"types": "./dist/main.d.ts", "types": "./dist/main.d.ts",
"homepage": "https://reacord.fly.dev", "homepage": "https://reacord.mapleleaf.dev",
"repository": "https://github.com/itsMapleLeaf/reacord.git", "repository": "https://github.com/itsMapleLeaf/reacord.git",
"changelog": "https://github.com/itsMapleLeaf/reacord/releases", "changelog": "https://github.com/itsMapleLeaf/reacord/releases",
"license": "MIT", "license": "MIT",
@@ -27,7 +27,8 @@
"exports": { "exports": {
".": { ".": {
"import": "./dist/main.js", "import": "./dist/main.js",
"require": "./dist/main.cjs" "require": "./dist/main.cjs",
"types": "./dist/main.d.ts"
}, },
"./package.json": { "./package.json": {
"import": "./package.json", "import": "./package.json",
@@ -35,24 +36,33 @@
} }
}, },
"scripts": { "scripts": {
"build": "tsup-node library/main.ts --target node16 --format cjs,esm --dts --sourcemap", "build": "cp ../../README.md . && cp ../../LICENSE . && tsx scripts/generate-exports.ts && tsup",
"build-watch": "pnpm build -- --watch", "build-watch": "pnpm build --watch",
"test": "vitest --coverage --no-watch", "test-manual": "tsx watch ./scripts/manual-test.tsx",
"test-dev": "vitest", "typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit", },
"playground": "nodemon --exec esmo --ext ts,tsx --inspect=5858 --enable-source-maps ./playground/main.tsx", "tsup": {
"release": "bash scripts/release.sh" "entry": [
"src/main.ts"
],
"sourcemap": true,
"target": "node16",
"format": [
"cjs",
"esm"
],
"dts": true
}, },
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
"@types/react": "*", "@types/react": "*",
"@types/react-reconciler": "^0.26.4", "@types/react-reconciler": "*",
"nanoid": "^3.1.31", "discord-api-types": "^0.37.1",
"react-reconciler": "^0.26.2", "react-reconciler": "^0.29.0",
"rxjs": "^7.5.2" "rxjs": "^7.5.6"
}, },
"peerDependencies": { "peerDependencies": {
"discord.js": "^13.3", "discord.js": "^14",
"react": ">=17" "react": ">=17"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
@@ -61,24 +71,24 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@types/lodash-es": "^4.17.5", "@reacord/helpers": "workspace:*",
"c8": "^7.11.0", "@types/lodash-es": "^4.17.6",
"discord.js": "^13.5.1", "@types/prettier": "^2.7.0",
"dotenv": "^11.0.0", "date-fns": "^2.29.1",
"esbuild": "latest", "discord.js": "^14.1.2",
"esbuild-jest": "^0.5.0", "dotenv": "^16.0.1",
"esmo": "^0.13.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"nodemon": "^2.0.15", "nodemon": "^2.0.19",
"prettier": "^2.5.1", "ora": "^6.1.2",
"pretty-ms": "^7.0.1", "prettier": "^2.7.1",
"react": "^17.0.2", "pretty-ms": "^8.0.0",
"release-it": "^14.12.1", "react": "^18.2.0",
"tsup": "^5.11.11", "release-it": "^15.2.0",
"type-fest": "^2.9.0", "ts-morph": "^15.1.0",
"typescript": "^4.5.4", "tsup": "^6.2.1",
"vite": "^2.7.10", "tsx": "^3.8.0",
"vitest": "^0.0.141" "type-fest": "^2.18.0",
"typescript": "^4.7.4"
}, },
"resolutions": { "resolutions": {
"esbuild": "latest" "esbuild": "latest"
@@ -91,5 +101,8 @@
"release": true, "release": true,
"web": true "web": true
} }
},
"publishConfig": {
"access": "public"
} }
} }
@@ -1,38 +0,0 @@
import type { Client, CommandInteraction } from "discord.js"
type Command = {
name: string
description: string
run: (interaction: CommandInteraction) => unknown
}
export function createCommandHandler(client: Client, commands: Command[]) {
client.on("ready", async () => {
for (const command of commands) {
for (const guild of client.guilds.cache.values()) {
await client.application?.commands.create(
{
name: command.name,
description: command.description,
},
guild.id,
)
}
}
})
client.on("interactionCreate", async (interaction) => {
if (!interaction.isCommand()) return
const command = commands.find(
(command) => command.name === interaction.commandName,
)
if (command) {
try {
await command.run(interaction)
} catch (error) {
console.error(error)
}
}
})
}
-36
View File
@@ -1,36 +0,0 @@
import * as React from "react"
import { Button, Embed, EmbedField, EmbedTitle } from "../library/main"
export function Counter(props: { onDeactivate: () => void }) {
const [count, setCount] = React.useState(0)
const [embedVisible, setEmbedVisible] = React.useState(false)
return (
<>
this button was clicked {count} times
{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"
emoji="<:plus_one:778531744860602388>"
label="clicc"
onClick={() => setCount(count + 1)}
/>
{!embedVisible && (
<Button label="show embed" onClick={() => setEmbedVisible(true)} />
)}
<Button style="danger" label="deactivate" onClick={props.onDeactivate} />
</>
)
}
@@ -1,31 +0,0 @@
import React, { useState } from "react"
import { Button, Option, Select } from "../library/main"
export function FruitSelect({
onConfirm,
}: {
onConfirm: (choice: string) => void
}) {
const [value, setValue] = useState<string>()
return (
<>
<Select
placeholder="choose a fruit"
value={value}
onChangeValue={setValue}
>
<Option value="🍎" />
<Option value="🍌" />
<Option value="🍒" />
</Select>
<Button
label="confirm"
disabled={value == undefined}
onClick={() => {
if (value) onConfirm(value)
}}
/>
</>
)
}
-109
View File
@@ -1,109 +0,0 @@
import { Client } from "discord.js"
import "dotenv/config"
import React from "react"
import { Button, ReacordDiscordJs, useInstance } from "../library/main"
import { createCommandHandler } from "./command-handler"
import { Counter } from "./counter"
import { FruitSelect } from "./fruit-select"
const client = new Client({
intents: ["GUILDS"],
})
const reacord = new ReacordDiscordJs(client)
client.on("ready", () => {
console.info("ready 💖")
// const now = new Date()
// function UptimeCounter() {
// const [uptime, setUptime] = React.useState(0)
// React.useEffect(() => {
// const interval = setInterval(() => {
// setUptime(Date.now() - now.getTime())
// }, 5000)
// return () => clearInterval(interval)
// }, [])
// return (
// <Embed>this bot has been running for {prettyMilliseconds(uptime)}</Embed>
// )
// }
// reacord.send("671787605624487941", <UptimeCounter />)
})
createCommandHandler(client, [
{
name: "button",
description: "it's a button",
run: (interaction) => {
reacord.reply(
interaction,
<Button label="clic" onClick={() => console.info("was clic")} />,
)
},
},
{
name: "counter",
description: "shows a counter button",
run: (interaction) => {
const reply = reacord.reply(interaction)
reply.render(<Counter onDeactivate={() => reply.destroy()} />)
},
},
{
name: "select",
description: "shows a select",
run: (interaction) => {
const instance = reacord.reply(
interaction,
<FruitSelect
onConfirm={(value) => {
instance.render(`you chose ${value}`)
instance.deactivate()
}}
/>,
)
},
},
{
name: "ephemeral-button",
description: "button which shows ephemeral messages",
run: (interaction) => {
reacord.reply(
interaction,
<>
<Button
label="public clic"
onClick={(event) =>
reacord.reply(
interaction,
`${event.guild?.member.displayName} clic`,
)
}
/>
<Button
label="clic"
onClick={(event) => event.ephemeralReply("you clic")}
/>
</>,
)
},
},
{
name: "delete-this",
description: "delete this",
run: (interaction) => {
function DeleteThis() {
const instance = useInstance()
return <Button label="delete this" onClick={() => instance.destroy()} />
}
reacord.reply(interaction, <DeleteThis />)
},
},
])
await client.login(process.env.TEST_BOT_TOKEN)
@@ -0,0 +1,62 @@
import { writeFile } from "node:fs/promises"
import { join, relative } from "node:path/posix"
import prettier from "prettier"
import { Node, Project, SyntaxKind } from "ts-morph"
function isDeclarationPublic(declaration: Node) {
if (!Node.isJSDocable(declaration)) return false
const jsDocTags = new Set(
declaration
.getJsDocs()
.flatMap((doc) => doc.getTags())
.map((tag) => tag.getTagName()),
)
return jsDocTags.has("category") && !jsDocTags.has("private")
}
const project = new Project()
project.addSourceFilesAtPaths(["src/**/*.{ts,tsx}", "!src/main.ts"])
const exportLines = project
.getSourceFiles()
.map((file) => {
const importPath = relative(
"src",
join(file.getDirectoryPath(), file.getBaseNameWithoutExtension()),
)
const exports = file.getExportedDeclarations()
const exportNames = [...exports].flatMap(([name, [declaration]]) => {
if (!declaration) return []
if (!isDeclarationPublic(declaration)) return []
if (
declaration.isKind(SyntaxKind.TypeAliasDeclaration) ||
declaration.isKind(SyntaxKind.InterfaceDeclaration)
) {
return `type ${name}`
}
return name
})
return { importPath, exportNames }
})
.filter(({ exportNames }) => exportNames.length > 0)
.map(({ importPath, exportNames }) => {
return `export { ${exportNames.join(", ")} } from "./${importPath}"`
})
const resolvedConfig = await prettier.resolveConfig("src/main.ts")
if (!resolvedConfig) {
throw new Error("Could not find prettier config")
}
await writeFile(
"src/main.ts",
prettier.format(exportLines.join(";"), {
...resolvedConfig,
parser: "typescript",
}),
)
-4
View File
@@ -1,4 +0,0 @@
pnpm build
cp ../../README.md .
cp ../../LICENSE .
pnpx release-it
@@ -0,0 +1,13 @@
import type { ClientOptions } from "discord.js"
import { Client } from "discord.js"
import { once } from "node:events"
export async function createDiscordClient(
token: string,
options: ClientOptions,
) {
const client = new Client(options)
await client.login(token)
const [readyClient] = await once(client, "ready")
return readyClient
}
+28
View File
@@ -0,0 +1,28 @@
export {
type ReacordConfig,
type InteractionInfo,
ReacordClient,
} from "./reacord-client"
export { type ReacordInstance } from "./reacord-instance"
export { ActionRow, type ActionRowProps } from "./react/action-row"
export { type ButtonSharedProps } from "./react/button-shared-props"
export { Button, type ButtonProps, type ButtonClickEvent } from "./react/button"
export { type ComponentEvent } from "./react/component-event"
export { EmbedAuthor, type EmbedAuthorProps } from "./react/embed-author"
export { EmbedField, type EmbedFieldProps } from "./react/embed-field"
export { EmbedFooter, type EmbedFooterProps } from "./react/embed-footer"
export { EmbedImage, type EmbedImageProps } from "./react/embed-image"
export {
EmbedThumbnail,
type EmbedThumbnailProps,
} from "./react/embed-thumbnail"
export { EmbedTitle, type EmbedTitleProps } from "./react/embed-title"
export { Embed, type EmbedProps } from "./react/embed"
export { useInstance } from "./react/instance-context"
export { Link, type LinkProps } from "./react/link"
export { Option, type OptionProps } from "./react/option"
export {
Select,
type SelectProps,
type SelectChangeEvent,
} from "./react/select"
@@ -0,0 +1,257 @@
import type {
APIActionRowComponent,
APIButtonComponent,
APIEmbed,
APISelectMenuComponent,
APISelectMenuOption,
} from "discord-api-types/v10"
import { ButtonStyle, ComponentType } from "discord-api-types/v10"
import type { Node } from "./node"
import { TextNode } from "./node"
import { ActionRowNode } from "./react/action-row"
import type { ButtonProps } from "./react/button"
import { ButtonNode } from "./react/button"
import { EmbedNode } from "./react/embed"
import { EmbedAuthorNode } from "./react/embed-author"
import {
EmbedFieldNameNode,
EmbedFieldNode,
EmbedFieldValueNode,
} from "./react/embed-field"
import { EmbedFooterNode } from "./react/embed-footer"
import { EmbedImageNode } from "./react/embed-image"
import { EmbedThumbnailNode } from "./react/embed-thumbnail"
import { EmbedTitleNode } from "./react/embed-title"
import { LinkNode } from "./react/link"
import {
OptionDescriptionNode,
OptionLabelNode,
OptionNode,
} from "./react/option"
import { SelectNode } from "./react/select"
export type MessageUpdatePayload = {
content: string | null
embeds: APIEmbed[]
components: Array<
APIActionRowComponent<APIButtonComponent | APISelectMenuComponent>
>
}
export function makeMessageUpdatePayload(root: Node): MessageUpdatePayload {
return {
// eslint-disable-next-line unicorn/no-null
content: root.extractText() || null,
embeds: makeEmbeds(root),
components: makeActionRows(root),
}
}
function makeEmbeds(root: Node) {
const embeds: APIEmbed[] = []
for (const node of root.children) {
if (node instanceof EmbedNode) {
const { props, children } = node
const embed: APIEmbed = {
author: props.author && {
name: props.author.name,
icon_url: props.author.iconUrl,
url: props.author.url,
},
color: props.color,
description: props.description,
fields: props.fields?.map(({ name, value, inline }) => ({
name,
value,
inline,
})),
footer: props.footer && {
text: props.footer.text,
icon_url: props.footer.iconUrl,
},
image: props.image,
thumbnail: props.thumbnail,
title: props.title,
url: props.url,
video: props.video,
}
if (props.timestamp !== undefined) {
embed.timestamp = normalizeDatePropToISOString(props.timestamp)
}
applyEmbedChildren(embed, children)
embeds.push(embed)
}
}
return embeds
}
function applyEmbedChildren(embed: APIEmbed, children: Node[]) {
for (const child of children) {
if (child instanceof EmbedAuthorNode) {
embed.author = {
name: child.extractText(),
icon_url: child.props.iconUrl,
url: child.props.url,
}
}
if (child instanceof EmbedFieldNode) {
embed.fields ??= []
embed.fields.push({
name: child.findInstanceOf(EmbedFieldNameNode)?.extractText() ?? "",
value:
child.findInstanceOf(EmbedFieldValueNode)?.extractText() || "_ _", // can't send an empty string
inline: child.props.inline,
})
}
if (child instanceof EmbedFooterNode) {
embed.footer = {
text: child.extractText(),
icon_url: child.props.iconUrl,
}
if (child.props.timestamp != undefined) {
embed.timestamp = normalizeDatePropToISOString(child.props.timestamp)
}
}
if (child instanceof EmbedImageNode) {
embed.image = { url: child.props.url }
}
if (child instanceof EmbedThumbnailNode) {
embed.thumbnail = { url: child.props.url }
}
if (child instanceof EmbedTitleNode) {
embed.title = child.extractText()
embed.url = child.props.url
}
if (child instanceof EmbedNode) {
applyEmbedChildren(embed, child.children)
}
if (child instanceof TextNode) {
embed.description ??= ""
embed.description += child.props.text
}
}
}
function normalizeDatePropToISOString(value: string | number | Date) {
return value instanceof Date
? value.toISOString()
: new Date(value).toISOString()
}
function makeActionRows(root: Node) {
const actionRows: Array<
APIActionRowComponent<APIButtonComponent | APISelectMenuComponent>
> = []
function getNextActionRow() {
let currentRow = actionRows[actionRows.length - 1]
if (
!currentRow ||
currentRow.components.length >= 5 ||
currentRow.components[0]?.type === ComponentType.SelectMenu
) {
currentRow = {
type: ComponentType.ActionRow,
components: [],
}
actionRows.push(currentRow)
}
return currentRow
}
for (const node of root.children) {
if (node instanceof ButtonNode) {
getNextActionRow().components.push({
type: ComponentType.Button,
custom_id: node.customId,
label: node.extractText(Number.POSITIVE_INFINITY),
emoji: node.props.emoji ? { name: node.props.emoji } : undefined,
style: translateButtonStyle(node.props.style ?? "secondary"),
disabled: node.props.disabled,
})
}
if (node instanceof LinkNode) {
getNextActionRow().components.push({
type: ComponentType.Button,
label: node.extractText(Number.POSITIVE_INFINITY),
url: node.props.url,
style: ButtonStyle.Link,
disabled: node.props.disabled,
})
}
if (node instanceof SelectNode) {
const actionRow: APIActionRowComponent<APISelectMenuComponent> = {
type: ComponentType.ActionRow,
components: [],
}
actionRows.push(actionRow)
let selectedValues: string[] = []
if (node.props.multiple && node.props.values) {
selectedValues = node.props.values ?? []
}
if (!node.props.multiple && node.props.value != undefined) {
selectedValues = [node.props.value]
}
const options = [...node.children]
.flatMap((child) => (child instanceof OptionNode ? child : []))
.map<APISelectMenuOption>((child) => ({
label:
child.findInstanceOf(OptionLabelNode)?.extractText() ||
child.props.value,
description: child
.findInstanceOf(OptionDescriptionNode)
?.extractText(),
value: child.props.value,
default: selectedValues.includes(child.props.value),
emoji: { name: child.props.emoji },
}))
const select: APISelectMenuComponent = {
type: ComponentType.SelectMenu,
custom_id: node.customId,
options,
disabled: node.props.disabled,
}
if (node.props.multiple) {
select.min_values = node.props.minValues
select.max_values = node.props.maxValues
}
actionRow.components.push(select)
}
if (node instanceof ActionRowNode) {
actionRows.push(...makeActionRows(node))
}
}
return actionRows
}
function translateButtonStyle(style: NonNullable<ButtonProps["style"]>) {
const styleMap = {
primary: ButtonStyle.Primary,
secondary: ButtonStyle.Secondary,
danger: ButtonStyle.Danger,
success: ButtonStyle.Success,
} as const
return styleMap[style]
}
+57
View File
@@ -0,0 +1,57 @@
export class Node<Props = unknown> {
readonly children: Node[] = []
constructor(public props: Props) {}
clear() {
this.children.splice(0)
}
add(...nodes: Node[]) {
this.children.push(...nodes)
}
remove(node: Node) {
const index = this.children.indexOf(node)
if (index !== -1) this.children.splice(index, 1)
}
insertBefore(node: Node, beforeNode: Node) {
const index = this.children.indexOf(beforeNode)
if (index !== -1) this.children.splice(index, 0, node)
}
replace(oldNode: Node, newNode: Node) {
const index = this.children.indexOf(oldNode)
if (index !== -1) this.children[index] = newNode
}
clone(): this {
const cloned: this = new (this.constructor as any)()
cloned.add(...this.children.map((child) => child.clone()))
return cloned
}
*walk(): Generator<Node> {
yield this
for (const child of this.children) {
yield* child.walk()
}
}
findInstanceOf<T extends Node>(
cls: new (...args: any[]) => T,
): T | undefined {
for (const child of this.children) {
if (child instanceof cls) return child
}
}
extractText(depth = 1): string {
if (this instanceof TextNode) return this.props.text
if (depth <= 0) return ""
return this.children.map((child) => child.extractText(depth - 1)).join("")
}
}
export class TextNode extends Node<{ text: string }> {}
+181
View File
@@ -0,0 +1,181 @@
import type { APIInteraction, Client } from "discord.js"
import {
GatewayDispatchEvents,
GatewayIntentBits,
InteractionResponseType,
InteractionType,
Routes,
} from "discord.js"
import * as React from "react"
import { createDiscordClient } from "./create-discord-client"
import type { ReacordInstance } from "./reacord-instance"
import { ReacordInstancePrivate } from "./reacord-instance"
import { InstanceProvider } from "./react/instance-context"
import type { Renderer } from "./renderer"
import {
ChannelMessageRenderer,
EphemeralInteractionReplyRenderer,
InteractionReplyRenderer,
} from "./renderer"
/**
* @category Core
*/
export type ReacordConfig = {
/** Discord bot token */
token: string
/**
* The max number of active instances.
* When this limit is exceeded, the oldest instances will be cleaned up
* to prevent memory leaks.
*/
maxInstances?: number
}
/**
* Info for replying to an interaction. For Discord.js
* (and probably other libraries) you should be able to pass the
* interaction object directly:
* ```js
* client.on("interactionCreate", (interaction) => {
* if (interaction.isChatInputCommand() && interaction.commandName === "hi") {
* reacord.reply(interacition, "hi lol")
* }
* })
* ```
* @category Core
*/
export type InteractionInfo = {
id: string
token: string
}
/**
* @category Core
*/
export class ReacordClient {
private readonly config: Required<ReacordConfig>
private readonly discordClientPromise: Promise<Client<true>>
private instances: ReacordInstancePrivate[] = []
destroyed = false
constructor(config: ReacordConfig) {
this.config = {
...config,
maxInstances: config.maxInstances ?? 50,
}
this.discordClientPromise = createDiscordClient(this.config.token, {
intents: [GatewayIntentBits.Guilds],
})
this.discordClientPromise
.then((client) => {
// we listen to the websocket message instead of the normal "interactionCreate" event,
// so that we can pass a library-agnostic APIInteraction object to the user's component callbacks
// the DJS MessageComponentInteraction doesn't have the raw data on it (as of writing this)
client.ws.on(
GatewayDispatchEvents.InteractionCreate,
async (interaction: APIInteraction) => {
if (interaction.type !== InteractionType.MessageComponent) return
// handling a component interaction may not always result in a re-render,
// and in the case that it doesn't, discord will incorrectly show "interaction failed",
// so here, we'll just always defer an update just in case
//
// we _can_ be a little smarter and check to see if an update happened before deferring,
// but I can figure that out later
//
// or we can make the user defer themselves if they don't update,
// but that's bad UX probably
await client.rest.post(
Routes.interactionCallback(interaction.id, interaction.token),
{ body: { type: InteractionResponseType.DeferredMessageUpdate } },
)
for (const instance of this.instances) {
instance.handleInteraction(interaction, this)
}
},
)
return client
})
.catch(console.error)
}
send(channelId: string, initialContent?: React.ReactNode) {
return this.createInstance(
new ChannelMessageRenderer(channelId, this.discordClientPromise),
initialContent,
)
}
reply(interaction: InteractionInfo, initialContent?: React.ReactNode) {
return this.createInstance(
new InteractionReplyRenderer(interaction, this.discordClientPromise),
initialContent,
)
}
ephemeralReply(
interaction: InteractionInfo,
initialContent?: React.ReactNode,
) {
return this.createInstance(
new EphemeralInteractionReplyRenderer(
interaction,
this.discordClientPromise,
),
initialContent,
)
}
destroy() {
void this.discordClientPromise.then((client) => client.destroy())
this.destroyed = true
}
private createInstance(renderer: Renderer, initialContent?: React.ReactNode) {
if (this.destroyed) throw new Error("ReacordClient is destroyed")
const instance = new ReacordInstancePrivate(renderer)
this.instances.push(instance)
if (this.instances.length > this.config.maxInstances) {
void this.instances[0]?.deactivate()
this.removeInstance(this.instances[0]!)
}
const publicInstance: ReacordInstance = {
render: (content: React.ReactNode) => {
instance.render(
React.createElement(
InstanceProvider,
{ value: publicInstance },
content,
),
)
},
deactivate: () => {
this.removeInstance(instance)
renderer.deactivate()
},
destroy: () => {
this.removeInstance(instance)
renderer.destroy()
},
}
if (initialContent !== undefined) {
publicInstance.render(initialContent)
}
return publicInstance
}
private removeInstance(instance: ReacordInstancePrivate) {
this.instances = this.instances.filter((the) => the !== instance)
}
}
+122
View File
@@ -0,0 +1,122 @@
import type {
APIMessageComponentButtonInteraction,
APIMessageComponentInteraction,
APIMessageComponentSelectMenuInteraction,
} from "discord.js"
import { ComponentType } from "discord.js"
import type * as React from "react"
import { Node } from "./node"
import type { ReacordClient } from "./reacord-client"
import { ButtonNode } from "./react/button"
import type { ComponentEvent } from "./react/component-event"
import { reconciler } from "./react/reconciler"
import type { SelectChangeEvent } from "./react/select"
import { SelectNode } from "./react/select"
import type { Renderer } from "./renderer"
/**
* Represents an interactive message, which can later be replaced or deleted.
* @category Core
*/
export type ReacordInstance = {
/** Render some JSX to this instance (edits the message) */
render(content: React.ReactNode): void
/** Remove this message */
destroy(): void
/**
* Same as destroy, but keeps the message and disables the components on it.
* This prevents it from listening to user interactions.
*/
deactivate(): void
}
export class ReacordInstancePrivate {
private readonly container = reconciler.createContainer(
this,
0,
// eslint-disable-next-line unicorn/no-null
null,
false,
// eslint-disable-next-line unicorn/no-null
null,
"reacord",
() => {},
// eslint-disable-next-line unicorn/no-null
null,
)
readonly tree = new Node({})
private latestTree?: Node
constructor(readonly renderer: Renderer) {}
render(content: React.ReactNode) {
reconciler.updateContainer(content, this.container)
}
update(tree: Node) {
this.renderer.update(tree)
this.latestTree = tree
}
deactivate() {
this.renderer.deactivate()
}
destroy() {
this.renderer.destroy()
}
handleInteraction(
interaction: APIMessageComponentInteraction,
client: ReacordClient,
) {
if (!this.latestTree) return
this.renderer.onComponentInteraction(interaction)
const baseEvent: ComponentEvent = {
reply: (content) => client.reply(interaction, content),
ephemeralReply: (content) => client.ephemeralReply(interaction, content),
}
if (interaction.data.component_type === ComponentType.Button) {
for (const node of this.latestTree.walk()) {
if (
node instanceof ButtonNode &&
node.customId === interaction.data.custom_id
) {
node.props.onClick({
...baseEvent,
interaction: interaction as APIMessageComponentButtonInteraction,
})
return
}
}
}
if (interaction.data.component_type === ComponentType.SelectMenu) {
const event: SelectChangeEvent = {
...baseEvent,
interaction: interaction as APIMessageComponentSelectMenuInteraction,
values: interaction.data.values,
}
for (const node of this.latestTree.walk()) {
if (
node instanceof SelectNode &&
node.customId === interaction.data.custom_id
) {
node.props.onChange?.(event)
node.props.onChangeMultiple?.(interaction.data.values, event)
if (interaction.data.values[0]) {
node.props.onChangeValue?.(interaction.data.values[0], event)
}
return
}
}
}
}
}
@@ -1,8 +1,7 @@
import type { ReactNode } from "react" import type { ReactNode } from "react"
import React from "react" import React from "react"
import { ReacordElement } from "../../internal/element.js" import { Node } from "../node"
import type { MessageOptions } from "../../internal/message" import { ReacordElement } from "./reacord-element"
import { Node } from "../../internal/node.js"
/** /**
* Props for an action row * Props for an action row
@@ -21,9 +20,9 @@ export type ActionRowProps = {
* ```tsx * ```tsx
* // put buttons on two separate rows * // put buttons on two separate rows
* <ActionRow> * <ActionRow>
* <Button onClick={handleFirst}>First</Button> * <Button label="First" onClick={handleFirst} />
* </ActionRow> * </ActionRow>
* <Button onClick={handleSecond}>Second</Button> * <Button label="Second" onClick={handleSecond} />
* ``` * ```
* *
* @category Action Row * @category Action Row
@@ -31,17 +30,10 @@ export type ActionRowProps = {
*/ */
export function ActionRow(props: ActionRowProps) { export function ActionRow(props: ActionRowProps) {
return ( return (
<ReacordElement props={props} createNode={() => new ActionRowNode(props)}> <ReacordElement props={{}} createNode={() => new ActionRowNode({})}>
{props.children} {props.children}
</ReacordElement> </ReacordElement>
) )
} }
class ActionRowNode extends Node<{}> { export class ActionRowNode extends Node<{}> {}
override modifyMessageOptions(options: MessageOptions): void {
options.actionRows.push([])
for (const child of this.children) {
child.modifyMessageOptions(options)
}
}
}
@@ -1,10 +1,12 @@
import type { ReactNode } from "react"
/** /**
* Common props between button-like components * Common props between button-like components
* @category Button * @category Button
*/ */
export type ButtonSharedProps = { export type ButtonSharedProps = {
/** The text on the button. Rich formatting (markdown) is not supported here. */ /** The text on the button. Rich formatting (markdown) is not supported here. */
label?: string label?: ReactNode
/** When true, the button will be slightly faded, and cannot be clicked. */ /** When true, the button will be slightly faded, and cannot be clicked. */
disabled?: boolean disabled?: boolean
+49
View File
@@ -0,0 +1,49 @@
import type { APIMessageComponentButtonInteraction } from "discord.js"
import { randomUUID } from "node:crypto"
import React from "react"
import { Node } from "../node"
import type { ButtonSharedProps } from "./button-shared-props"
import type { ComponentEvent } from "./component-event"
import { ReacordElement } from "./reacord-element"
/**
* @category Button
*/
export type ButtonProps = ButtonSharedProps & {
/**
* The style determines the color of the button and signals intent.
* @see https://discord.com/developers/docs/interactions/message-components#button-object-button-styles
*/
style?: "primary" | "secondary" | "success" | "danger"
/**
* Happens when a user clicks the button.
*/
onClick: (event: ButtonClickEvent) => void
}
/**
* @category Button
*/
export type ButtonClickEvent = ComponentEvent & {
/**
* Event details, e.g. the user who clicked, guild member, guild id, etc.
* @see https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object
*/
interaction: APIMessageComponentButtonInteraction
}
/**
* @category Button
*/
export function Button(props: ButtonProps) {
return (
<ReacordElement props={props} createNode={() => new ButtonNode(props)}>
{props.label}
</ReacordElement>
)
}
export class ButtonNode extends Node<ButtonProps> {
readonly customId = randomUUID()
}
@@ -0,0 +1,18 @@
import type { ReactNode } from "react"
import type { ReacordInstance } from "../reacord-instance"
/**
* @category Component Event
*/
export type ComponentEvent = {
/**
* Create a new reply to this event.
*/
reply(content?: ReactNode): ReacordInstance
/**
* Create an ephemeral reply to this event,
* shown only to the user who triggered it.
*/
ephemeralReply(content?: ReactNode): ReacordInstance
}
@@ -0,0 +1,27 @@
import type { ReactNode } from "react"
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Embed
*/
export type EmbedAuthorProps = {
name?: ReactNode
children?: ReactNode
url?: string
iconUrl?: string
}
/**
* @category Embed
*/
export function EmbedAuthor(props: EmbedAuthorProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedAuthorNode(props)}>
{props.name ?? props.children}
</ReacordElement>
)
}
export class EmbedAuthorNode extends Node<EmbedAuthorProps> {}
@@ -0,0 +1,34 @@
import type { ReactNode } from "react"
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Embed
*/
export type EmbedFieldProps = {
name: ReactNode
value?: ReactNode
inline?: boolean
children?: ReactNode
}
/**
* @category Embed
*/
export function EmbedField(props: EmbedFieldProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedFieldNode(props)}>
<ReacordElement props={{}} createNode={() => new EmbedFieldNameNode({})}>
{props.name}
</ReacordElement>
<ReacordElement props={{}} createNode={() => new EmbedFieldValueNode({})}>
{props.value ?? props.children}
</ReacordElement>
</ReacordElement>
)
}
export class EmbedFieldNode extends Node<EmbedFieldProps> {}
export class EmbedFieldNameNode extends Node<{}> {}
export class EmbedFieldValueNode extends Node<{}> {}
@@ -0,0 +1,29 @@
import type { ReactNode } from "react"
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Embed
*/
export type EmbedFooterProps = {
text?: ReactNode
children?: ReactNode
iconUrl?: string
timestamp?: string | number | Date
}
/**
* @category Embed
*/
export function EmbedFooter({ text, children, ...props }: EmbedFooterProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedFooterNode(props)}>
{text ?? children}
</ReacordElement>
)
}
export class EmbedFooterNode extends Node<
Omit<EmbedFooterProps, "text" | "children">
> {}
@@ -0,0 +1,24 @@
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Embed
*/
export type EmbedImageProps = {
url: string
}
/**
* @category Embed
*/
export function EmbedImage(props: EmbedImageProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedImageNode(props)}
/>
)
}
export class EmbedImageNode extends Node<EmbedImageProps> {}
@@ -0,0 +1,24 @@
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Embed
*/
export type EmbedThumbnailProps = {
url: string
}
/**
* @category Embed
*/
export function EmbedThumbnail(props: EmbedThumbnailProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedThumbnailNode(props)}
/>
)
}
export class EmbedThumbnailNode extends Node<EmbedThumbnailProps> {}
@@ -0,0 +1,26 @@
import type { ReactNode } from "react"
import React from "react"
import type { Except } from "type-fest"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Embed
*/
export type EmbedTitleProps = {
children: ReactNode
url?: string
}
/**
* @category Embed
*/
export function EmbedTitle({ children, ...props }: EmbedTitleProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedTitleNode(props)}>
{children}
</ReacordElement>
)
}
export class EmbedTitleNode extends Node<Except<EmbedTitleProps, "children">> {}
+36
View File
@@ -0,0 +1,36 @@
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Embed
* @see https://discord.com/developers/docs/resources/channel#embed-object
*/
export type EmbedProps = {
title?: string
description?: string
url?: string
color?: number
fields?: Array<{ name: string; value: string; inline?: boolean }>
author?: { name: string; url?: string; iconUrl?: string }
thumbnail?: { url: string }
image?: { url: string }
video?: { url: string }
footer?: { text: string; iconUrl?: string }
timestamp?: string | number | Date
children?: React.ReactNode
}
/**
* @category Embed
* @see https://discord.com/developers/docs/resources/channel#embed-object
*/
export function Embed(props: EmbedProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedNode(props)}>
{props.children}
</ReacordElement>
)
}
export class EmbedNode extends Node<EmbedProps> {}
@@ -1,6 +1,6 @@
import { raise } from "@reacord/helpers/raise"
import * as React from "react" import * as React from "react"
import { raise } from "../../helpers/raise" import type { ReacordInstance } from "../reacord-instance"
import type { ReacordInstance } from "./instance"
const Context = React.createContext<ReacordInstance | undefined>(undefined) const Context = React.createContext<ReacordInstance | undefined>(undefined)
@@ -10,7 +10,7 @@ export const InstanceProvider = Context.Provider
* Get the associated instance for the current component. * Get the associated instance for the current component.
* *
* @category Core * @category Core
* @see https://reacord.fly.dev/guides/use-instance * @see https://reacord.mapleleaf.dev/guides/use-instance
*/ */
export function useInstance(): ReacordInstance { export function useInstance(): ReacordInstance {
return ( return (
+28
View File
@@ -0,0 +1,28 @@
import React from "react"
import type { Except } from "type-fest"
import { Node } from "../node"
import type { ButtonSharedProps } from "./button-shared-props"
import { ReacordElement } from "./reacord-element"
/**
* @category Link
*/
export type LinkProps = ButtonSharedProps & {
/** The URL the link should lead to */
url: string
/** The link text */
children?: string
}
/**
* @category Link
*/
export function Link({ label, children, ...props }: LinkProps) {
return (
<ReacordElement props={props} createNode={() => new LinkNode(props)}>
{label || children}
</ReacordElement>
)
}
export class LinkNode extends Node<Except<LinkProps, "label" | "children">> {}
+64
View File
@@ -0,0 +1,64 @@
import type { ReactNode } from "react"
import React from "react"
import { Node } from "../node"
import { ReacordElement } from "./reacord-element"
/**
* @category Select
*/
export type OptionProps = {
/** The internal value of this option */
value: string
/** The text shown to the user. This takes priority over `children` */
label?: ReactNode
/** The text shown to the user */
children?: ReactNode
/** Description for the option, shown to the user */
description?: ReactNode
/**
* Renders an emoji to the left of the text.
*
* Has to be a literal emoji character (e.g. 🍍),
* or an emoji code, like `<:plus_one:778531744860602388>`.
*
* To get an emoji code, type your emoji in Discord chat
* with a backslash `\` in front.
* The bot has to be in the emoji's guild to use it.
*/
emoji?: string
}
/**
* @category Select
*/
export function Option({
label,
children,
description,
...props
}: OptionProps) {
return (
<ReacordElement props={props} createNode={() => new OptionNode(props)}>
{(label !== undefined || children !== undefined) && (
<ReacordElement props={{}} createNode={() => new OptionLabelNode({})}>
{label || children}
</ReacordElement>
)}
{description !== undefined && (
<ReacordElement
props={{}}
createNode={() => new OptionDescriptionNode({})}
>
{description}
</ReacordElement>
)}
</ReacordElement>
)
}
export class OptionNode extends Node<
Omit<OptionProps, "children" | "label" | "description">
> {}
export class OptionLabelNode extends Node<{}> {}
export class OptionDescriptionNode extends Node<{}> {}
@@ -1,6 +1,6 @@
import type { ReactNode } from "react" import type { ReactNode } from "react"
import React from "react" import React from "react"
import type { Node } from "./node" import type { Node } from "../node"
export function ReacordElement<Props>(props: { export function ReacordElement<Props>(props: {
props: Props props: Props
@@ -1,15 +1,15 @@
import type { HostConfig } from "react-reconciler" /* eslint-disable unicorn/prefer-modern-dom-apis */
import { raise } from "@reacord/helpers/raise"
import ReactReconciler from "react-reconciler" import ReactReconciler from "react-reconciler"
import { raise } from "../../helpers/raise.js" import { DefaultEventPriority } from "react-reconciler/constants"
import { Node } from "./node.js" import { Node, TextNode } from "../node"
import type { Renderer } from "./renderers/renderer" import type { ReacordInstancePrivate } from "../reacord-instance"
import { TextNode } from "./text-node.js"
const config: HostConfig< export const reconciler = ReactReconciler<
string, // Type, string, // Type,
Record<string, unknown>, // Props, Record<string, unknown>, // Props,
Renderer, // Container, ReacordInstancePrivate, // Container,
Node<unknown>, // Instance, Node, // Instance,
TextNode, // TextInstance, TextNode, // TextInstance,
never, // SuspenseInstance, never, // SuspenseInstance,
never, // HydratableInstance, never, // HydratableInstance,
@@ -19,9 +19,7 @@ const config: HostConfig<
never, // ChildSet, never, // ChildSet,
number, // TimeoutHandle, number, // TimeoutHandle,
number // NoTimeout, number // NoTimeout,
> = { >({
// config
now: Date.now,
supportsMutation: true, supportsMutation: true,
supportsPersistence: false, supportsPersistence: false,
supportsHydration: false, supportsHydration: false,
@@ -50,33 +48,40 @@ const config: HostConfig<
return node return node
}, },
createTextInstance: (text) => new TextNode(text), createTextInstance: (text) => new TextNode({ text }),
shouldSetTextContent: () => false, shouldSetTextContent: () => false,
detachDeletedInstance: (instance) => {},
beforeActiveInstanceBlur: () => {},
afterActiveInstanceBlur: () => {},
// eslint-disable-next-line unicorn/no-null
getInstanceFromNode: (node: any) => null,
// eslint-disable-next-line unicorn/no-null
getInstanceFromScope: (scopeInstance: any) => null,
clearContainer: (renderer) => { clearContainer: (instance) => {
renderer.nodes.clear() instance.tree.clear()
}, },
appendChildToContainer: (renderer, child) => { appendChildToContainer: (instance, child) => {
renderer.nodes.add(child) instance.tree.add(child)
}, },
removeChildFromContainer: (renderer, child) => { removeChildFromContainer: (instance, child) => {
renderer.nodes.remove(child) instance.tree.remove(child)
}, },
insertInContainerBefore: (renderer, child, before) => { insertInContainerBefore: (instance, child, before) => {
renderer.nodes.addBefore(child, before) instance.tree.insertBefore(child, before)
}, },
appendInitialChild: (parent, child) => { appendInitialChild: (parent, child) => {
parent.children.add(child) parent.add(child)
}, },
appendChild: (parent, child) => { appendChild: (parent, child) => {
parent.children.add(child) parent.add(child)
}, },
removeChild: (parent, child) => { removeChild: (parent, child) => {
parent.children.remove(child) parent.remove(child)
}, },
insertBefore: (parent, child, before) => { insertBefore: (parent, child, before) => {
parent.children.addBefore(child, before) parent.insertBefore(child, before)
}, },
prepareUpdate: () => true, prepareUpdate: () => true,
@@ -84,19 +89,20 @@ const config: HostConfig<
node.props = newProps.props node.props = newProps.props
}, },
commitTextUpdate: (node, oldText, newText) => { commitTextUpdate: (node, oldText, newText) => {
node.props = newText node.props.text = newText
}, },
// eslint-disable-next-line unicorn/no-null // eslint-disable-next-line unicorn/no-null
prepareForCommit: () => null, prepareForCommit: () => null,
resetAfterCommit: (renderer) => { resetAfterCommit: (renderer) => {
renderer.render() void renderer.update(renderer.tree)
}, },
prepareScopeUpdate: (scopeInstance: any, instance: any) => {},
preparePortalMount: () => raise("Portals are not supported"), preparePortalMount: () => raise("Portals are not supported"),
getPublicInstance: () => raise("Refs are currently not supported"), getPublicInstance: () => raise("Refs are currently not supported"),
finalizeInitialChildren: () => false, finalizeInitialChildren: () => false,
}
export const reconciler = ReactReconciler(config) getCurrentEventPriority: () => DefaultEventPriority,
})

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