158 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
148 changed files with 8300 additions and 6055 deletions

8
.changeset/README.md Normal file
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
.changeset/config.json Normal file
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
.eslintrc.cjs Normal file
View File

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

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"
}
}
]
}

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"

View File

@@ -9,6 +9,11 @@ env:
TEST_BOT_TOKEN: ${{ secrets.TEST_BOT_TOKEN }}
TEST_CHANNEL_ID: ${{ secrets.TEST_CHANNEL_ID }}
TEST_GUILD_ID: ${{ secrets.TEST_GUILD_ID }}
TEST_CATEGORY_ID: ${{ secrets.TEST_CATEGORY_ID }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
run-commands:
@@ -16,26 +21,30 @@ jobs:
fail-fast: false
matrix:
command:
# if these run in the same process, it dies,
# if tests run in the same process, it dies,
# so we test them separate
- name: test reacord
run: pnpm test -C packages/reacord
- name: test
run: pnpm test
- 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
run: pnpm build --recursive
run: pnpm --recursive run build
- name: lint
run: pnpm lint
run: pnpm run lint
- name: typecheck
run: pnpm typecheck --parallel
run: pnpm --recursive run typecheck
name: ${{ matrix.command.name }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2
with:
version: 7.8.0
- uses: actions/setup-node@v2
with:
# https://github.com/actions/setup-node#supported-version-syntax
node-version: "16"
- run: npm i -g pnpm
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: ${{ matrix.command.run }}

37
.github/workflows/release.yml vendored Normal file
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
.gitignore vendored
View File

@@ -3,4 +3,8 @@ node_modules
.vscode
coverage
.env
*.code-workspace
.pnpm-debug.log
build
.cache

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
ignore-workspace-root-check = true

View File

@@ -4,3 +4,4 @@ coverage
pnpm-lock.yaml
build
.cache
packages/website/public/api

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" ]

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2022 itsMapleLeaf
Copyright (c) 2022 itsMapleLeaf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -18,7 +18,7 @@ pnpm add reacord react discord.js
## 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

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"

View File

@@ -1,26 +1,28 @@
{
"name": "reacord-monorepo",
"private": true,
"scripts": {
"lint": "eslint --ext js,ts,tsx .",
"lint-fix": "pnpm lint -- --fix",
"format": "prettier --write ."
},
"dependencies": {
"@itsmapleleaf/configs": "^1.1.2"
"test": "vitest --coverage --no-watch",
"test-dev": "vitest --ui",
"format": "prettier --write .",
"build": "pnpm -r run build",
"start": "pnpm -C packages/website run start",
"release": "pnpm -r run build && changeset publish"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.9.1",
"@typescript-eslint/parser": "^5.9.1",
"eslint": "^8.6.0",
"eslint-config-prettier": "^8.3.0",
"eslint-import-resolver-typescript": "^2.5.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-unicorn": "^40.0.0",
"prettier": "^2.5.1",
"typescript": "^4.5.4"
"@changesets/cli": "^2.24.2",
"@itsmapleleaf/configs": "^1.1.5",
"@rushstack/eslint-patch": "^1.1.4",
"@types/eslint": "^8.4.5",
"@vitest/ui": "^0.21.0",
"c8": "^7.12.0",
"eslint": "^8.21.0",
"node": "^16.16.0",
"prettier": "^2.7.1",
"typescript": "^4.7.4",
"vitest": "^0.21.0"
},
"resolutions": {
"esbuild": "latest"

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { inspect } from "node:util"
// eslint-disable-next-line import/no-unused-modules
export function logPretty(value: unknown) {
console.info(
inspect(value, {

View File

@@ -1,4 +1,3 @@
// eslint-disable-next-line import/no-unused-modules
export function omit<Subject extends object, Key extends PropertyKey>(
subject: Subject,
keys: Key[],

View File

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

View File

@@ -1,6 +1,5 @@
import type { LoosePick, UnknownRecord } from "./types"
// eslint-disable-next-line import/no-unused-modules
export function pick<T, K extends keyof T | PropertyKey>(
object: T,
keys: K[],

View File

@@ -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)
})

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]

View File

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

View File

@@ -1,4 +1,3 @@
/* eslint-disable import/no-unused-modules */
export type MaybePromise<T> = T | Promise<T>
export type ValueOf<Type> = Type extends ReadonlyArray<infer Value>

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")
}

View File

@@ -1,6 +1,5 @@
import { inspect } from "node:util"
// eslint-disable-next-line import/no-unused-modules
export function withLoggedMethodCalls<T extends object>(value: T) {
return new Proxy(value as Record<string | symbol, unknown>, {
get(target, property) {

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -1,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
}
}

View File

@@ -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,
}
}
}

View File

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

View File

@@ -1,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,
})
}
}

View File

@@ -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
}
}

View File

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

View File

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

View File

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

View File

@@ -1,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
}
}

View File

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

View File

@@ -1,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,
})
}
}

View File

@@ -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,
}
}
}

View File

@@ -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)} />
)
}

View File

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

View File

@@ -1,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
}

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()
}
}

View File

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

View File

@@ -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]()
}
}

View File

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

View File

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

View File

@@ -1,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
}

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
}
}

View File

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

View File

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

View File

@@ -1,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)
}
}

View File

@@ -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
}
}

View File

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

View File

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

View File

@@ -2,9 +2,9 @@
"name": "reacord",
"type": "module",
"description": "Create interactive Discord messages using React.",
"version": "0.3.4",
"version": "0.5.1",
"types": "./dist/main.d.ts",
"homepage": "https://reacord.fly.dev",
"homepage": "https://reacord.mapleleaf.dev",
"repository": "https://github.com/itsMapleLeaf/reacord.git",
"changelog": "https://github.com/itsMapleLeaf/reacord/releases",
"license": "MIT",
@@ -27,7 +27,8 @@
"exports": {
".": {
"import": "./dist/main.js",
"require": "./dist/main.cjs"
"require": "./dist/main.cjs",
"types": "./dist/main.d.ts"
},
"./package.json": {
"import": "./package.json",
@@ -35,24 +36,33 @@
}
},
"scripts": {
"build": "tsup-node library/main.ts --target node16 --format cjs,esm --dts --sourcemap",
"build-watch": "pnpm build -- --watch",
"test": "vitest --coverage --no-watch",
"test-dev": "vitest",
"typecheck": "tsc --noEmit",
"playground": "nodemon --exec esmo --ext ts,tsx --inspect=5858 --enable-source-maps ./playground/main.tsx",
"release": "bash scripts/release.sh"
"build": "cp ../../README.md . && cp ../../LICENSE . && tsx scripts/generate-exports.ts && tsup",
"build-watch": "pnpm build --watch",
"test-manual": "tsx watch ./scripts/manual-test.tsx",
"typecheck": "tsc --noEmit"
},
"tsup": {
"entry": [
"src/main.ts"
],
"sourcemap": true,
"target": "node16",
"format": [
"cjs",
"esm"
],
"dts": true
},
"dependencies": {
"@types/node": "*",
"@types/react": "*",
"@types/react-reconciler": "^0.26.4",
"nanoid": "^3.1.31",
"react-reconciler": "^0.26.2",
"rxjs": "^7.5.2"
"@types/react-reconciler": "*",
"discord-api-types": "^0.37.1",
"react-reconciler": "^0.29.0",
"rxjs": "^7.5.6"
},
"peerDependencies": {
"discord.js": "^13.3",
"discord.js": "^14",
"react": ">=17"
},
"peerDependenciesMeta": {
@@ -61,24 +71,24 @@
}
},
"devDependencies": {
"@types/lodash-es": "^4.17.5",
"c8": "^7.11.0",
"discord.js": "^13.5.1",
"dotenv": "^11.0.0",
"esbuild": "latest",
"esbuild-jest": "^0.5.0",
"esmo": "^0.13.0",
"@reacord/helpers": "workspace:*",
"@types/lodash-es": "^4.17.6",
"@types/prettier": "^2.7.0",
"date-fns": "^2.29.1",
"discord.js": "^14.1.2",
"dotenv": "^16.0.1",
"lodash-es": "^4.17.21",
"nodemon": "^2.0.15",
"prettier": "^2.5.1",
"pretty-ms": "^7.0.1",
"react": "^17.0.2",
"release-it": "^14.12.1",
"tsup": "^5.11.11",
"type-fest": "^2.9.0",
"typescript": "^4.5.4",
"vite": "^2.7.10",
"vitest": "^0.0.141"
"nodemon": "^2.0.19",
"ora": "^6.1.2",
"prettier": "^2.7.1",
"pretty-ms": "^8.0.0",
"react": "^18.2.0",
"release-it": "^15.2.0",
"ts-morph": "^15.1.0",
"tsup": "^6.2.1",
"tsx": "^3.8.0",
"type-fest": "^2.18.0",
"typescript": "^4.7.4"
},
"resolutions": {
"esbuild": "latest"
@@ -91,5 +101,8 @@
"release": true,
"web": true
}
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -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)
}
}
})
}

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} />
</>
)
}

View File

@@ -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)
}}
/>
</>
)
}

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)

View File

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

View File

@@ -1,4 +0,0 @@
pnpm build
cp ../../README.md .
cp ../../LICENSE .
pnpx release-it

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
import type { ReactNode } from "react"
/**
* Common props between button-like components
* @category Button
*/
export type ButtonSharedProps = {
/** The text on the button. Rich formatting (markdown) is not supported here. */
label?: string
label?: ReactNode
/** When true, the button will be slightly faded, and cannot be clicked. */
disabled?: boolean

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { raise } from "@reacord/helpers/raise"
import * as React from "react"
import { raise } from "../../helpers/raise"
import type { ReacordInstance } from "./instance"
import type { ReacordInstance } from "../reacord-instance"
const Context = React.createContext<ReacordInstance | undefined>(undefined)
@@ -10,7 +10,7 @@ export const InstanceProvider = Context.Provider
* Get the associated instance for the current component.
*
* @category Core
* @see https://reacord.fly.dev/guides/use-instance
* @see https://reacord.mapleleaf.dev/guides/use-instance
*/
export function useInstance(): ReacordInstance {
return (

View File

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

View File

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

View File

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

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