75 Commits
astro ... main

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

View File

@@ -1,38 +0,0 @@
require("@rushstack/eslint-patch/modern-module-resolution")
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [require.resolve("@itsmapleleaf/configs/eslint")],
ignorePatterns: [
"**/node_modules/**",
"**/.cache/**",
"**/build/**",
"**/dist/**",
"**/coverage/**",
"**/public/**",
],
parserOptions: {
project: require.resolve("./tsconfig.base.json"),
extraFileExtensions: [".astro"],
},
overrides: [
{
files: ["packages/website/cypress/**"],
parserOptions: {
project: require.resolve("./packages/website/cypress/tsconfig.json"),
},
},
{
files: ["*.astro"],
parser: "astro-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
},
rules: {
"react/no-unknown-property": "off",
"react/jsx-key": "off",
"react/jsx-no-undef": "off",
},
},
],
}

View File

@@ -1,53 +0,0 @@
name: main
on:
push:
branches: [main]
pull_request:
env:
TEST_BOT_TOKEN: ${{ secrets.TEST_BOT_TOKEN }}
TEST_CHANNEL_ID: ${{ secrets.TEST_CHANNEL_ID }}
TEST_GUILD_ID: ${{ secrets.TEST_GUILD_ID }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
run-commands:
strategy:
fail-fast: false
matrix:
command:
# if these run in the same process, it dies,
# so we test them separate
- name: test reacord
run: pnpm -C packages/reacord test
# - name: test website
# run: pnpm -C packages/website test
- name: build
run: pnpm --recursive run build
- name: lint
run: pnpm run lint
- name: typecheck
run: pnpm --recursive run typecheck
name: ${{ matrix.command.name }}
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v3
- name: setup pnpm
uses: pnpm/action-setup@v2
with:
version: 7.13.4
- name: setup node
uses: actions/setup-node@v3
with:
node-version: 16
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: ${{ matrix.command.run }}

View File

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

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

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

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -1,42 +1,30 @@
<center>
<img src="./packages/website/app/assets/banner.png" alt="Reacord: Create interactive Discord messages using React">
</center>
## Installation ∙ [![npm](https://img.shields.io/npm/v/reacord?color=blue&style=flat-square)](https://www.npmjs.com/package/reacord)
```console
# npm
npm install reacord react discord.js
# yarn
yarn add reacord react discord.js
# pnpm
pnpm add reacord react discord.js
# bun
bun add reacord react discord.js
```
## Get Started
[Visit the docs to get started.](https://reacord.mapleleaf.dev/guides/getting-started)
## Example
<!-- prettier-ignore -->
```tsx
import * as React from "react"
import { useState } from "react"
import { Embed, Button } from "reacord"
function Counter() {
const [count, setCount] = React.useState(0)
const [count, setCount] = useState(0)
return (
<>
<Embed title="Counter">
This button has been clicked {count} times.
</Embed>
<Button onClick={() => setCount(count + 1)}>
+1
</Button>
<Button
label="+1"
onClick={() => setCount(count + 1)}
/>
</>
)
}

1412
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,41 @@
{
"name": "reacord-monorepo",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"lint": "eslint --ext js,ts,tsx .",
"lint-fix": "pnpm lint -- --fix",
"format": "prettier --write .",
"build": "pnpm -r run build",
"start": "pnpm -C packages/website run start",
"release": "pnpm -r run build && changeset publish"
"lint": "run-s --continue-on-error lint:*",
"lint:eslint": "eslint . --fix --cache --cache-file=node_modules/.cache/.eslintcache --report-unused-disable-directives",
"lint:prettier": "prettier . \"**/*.astro\" --write --cache --list-different",
"lint:types": "bun run --cwd packages/helpers typecheck && bun run --cwd packages/reacord typecheck",
"test": "vitest",
"build": "bun run --cwd packages/reacord build",
"release": "bun run build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.25.0",
"@itsmapleleaf/configs": "^1.1.7",
"@rushstack/eslint-patch": "^1.2.0",
"@types/eslint": "^8.4.6",
"astro-eslint-parser": "^0.12.0",
"eslint": "^8.36.0",
"prettier": "^2.7.1",
"prettier-plugin-astro": "^0.8.0",
"typescript": "^4.8.4"
"@changesets/cli": "^2.26.2",
"@itsmapleleaf/configs": "github:itsMapleLeaf/configs",
"eslint": "^8.51.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.0.3",
"react": "^18.2.0",
"typescript": "^5.2.2",
"vitest": "^0.34.6"
},
"resolutions": {
"esbuild": "latest"
"prettier": "@itsmapleleaf/configs/prettier",
"eslintConfig": {
"extends": [
"./node_modules/@itsmapleleaf/configs/eslint.config.cjs",
"./node_modules/@itsmapleleaf/configs/eslint.config.react.cjs"
],
"ignorePatterns": [
"node_modules",
"dist"
],
"rules": {
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/require-await": "off"
}
}
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -1,52 +1,60 @@
import type { ReactNode } from "react"
import type { ReacordInstance } from "./instance"
/** @category Component Event */
export interface ComponentEvent {
/**
* @category Component Event
*/
export type ComponentEvent = {
/**
* The message associated with this event.
* For example: with a button click,
* 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
message: ComponentEventMessage
/**
* The channel that this event occurred in.
*
* @see https://discord.com/developers/docs/resources/channel#channel-object
*/
channel: ChannelInfo
channel: ComponentEventChannel
/**
* The user that triggered this event.
*
* @see https://discord.com/developers/docs/resources/user#user-object
*/
user: UserInfo
user: ComponentEventUser
/**
* The guild that this event occurred in.
*
* @see https://discord.com/developers/docs/resources/guild#guild-object
*/
guild?: GuildInfo
guild?: ComponentEventGuild
/** Create a new reply to this event. */
reply(
content?: ReactNode,
options?: ComponentEventReplyOptions,
): ReacordInstance
/**
* 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.
* Create an ephemeral reply to this event, shown only to the user who
* triggered it.
*
* @deprecated Use event.reply(content, { ephemeral: true })
*/
ephemeralReply(content?: ReactNode): ReacordInstance
}
/**
* @category Component Event
*/
export type ChannelInfo = {
/** @category Component Event */
export interface ComponentEventReplyOptions {
ephemeral?: boolean
tts?: boolean
}
/** @category Component Event */
export interface ComponentEventChannel {
id: string
name?: string
topic?: string
@@ -57,14 +65,12 @@ export type ChannelInfo = {
rateLimitPerUser?: number
}
/**
* @category Component Event
*/
export type MessageInfo = {
/** @category Component Event */
export interface ComponentEventMessage {
id: string
channelId: string
authorId: UserInfo
member?: GuildMemberInfo
authorId: string
member?: ComponentEventGuildMember
content: string
timestamp: string
editedTimestamp?: string
@@ -74,19 +80,15 @@ export type MessageInfo = {
mentions: string[]
}
/**
* @category Component Event
*/
export type GuildInfo = {
/** @category Component Event */
export interface ComponentEventGuild {
id: string
name: string
member: GuildMemberInfo
member: ComponentEventGuildMember
}
/**
* @category Component Event
*/
export type GuildMemberInfo = {
/** @category Component Event */
export interface ComponentEventGuildMember {
id: string
nick?: string
displayName: string
@@ -100,14 +102,12 @@ export type GuildMemberInfo = {
communicationDisabledUntil?: string
}
/**
* @category Component Event
*/
export type UserInfo = {
/** @category Component Event */
export interface ComponentEventUser {
id: string
username: string
discriminator: string
tag: string
avatarUrl: string
avatarUrl: string | null
accentColor?: number
}

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,18 @@
import type { ReactNode } from "react"
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { Node } from "../../internal/node.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedAuthorProps = {
/** @category Embed */
export interface EmbedAuthorProps {
name?: ReactNode
children?: ReactNode
url?: string
iconUrl?: string
}
/**
* @category Embed
*/
/** @category Embed */
export function EmbedAuthor(props: EmbedAuthorProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedAuthorNode(props)}>
@@ -38,4 +33,4 @@ class EmbedAuthorNode extends EmbedChildNode<EmbedAuthorProps> {
}
}
class AuthorTextNode extends Node<{}> {}
class AuthorTextNode extends Node<Record<string, never>> {}

View File

@@ -1,23 +1,18 @@
import type { ReactNode } from "react"
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { Node } from "../../internal/node.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedFieldProps = {
/** @category Embed */
export interface EmbedFieldProps {
name: ReactNode
value?: ReactNode
inline?: boolean
children?: ReactNode
}
/**
* @category Embed
*/
/** @category Embed */
export function EmbedField(props: EmbedFieldProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedFieldNode(props)}>
@@ -25,7 +20,7 @@ export function EmbedField(props: EmbedFieldProps) {
{props.name}
</ReacordElement>
<ReacordElement props={{}} createNode={() => new FieldValueNode({})}>
{props.value || props.children}
{props.value ?? props.children}
</ReacordElement>
</ReacordElement>
)
@@ -42,5 +37,5 @@ class EmbedFieldNode extends EmbedChildNode<EmbedFieldProps> {
}
}
class FieldNameNode extends Node<{}> {}
class FieldValueNode extends Node<{}> {}
class FieldNameNode extends Node<Record<string, never>> {}
class FieldValueNode extends Node<Record<string, never>> {}

View File

@@ -1,23 +1,18 @@
import type { ReactNode } from "react"
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import { Node } from "../../internal/node.js"
import { EmbedChildNode } from "./embed-child.js"
import type { EmbedOptions } from "./embed-options"
/**
* @category Embed
*/
export type EmbedFooterProps = {
/** @category Embed */
export interface EmbedFooterProps {
text?: ReactNode
children?: ReactNode
iconUrl?: string
timestamp?: string | number | Date
}
/**
* @category Embed
*/
/** @category Embed */
export function EmbedFooter({ text, children, ...props }: EmbedFooterProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedFooterNode(props)}>
@@ -42,4 +37,4 @@ class EmbedFooterNode extends EmbedChildNode<
}
}
class FooterTextNode extends Node<{}> {}
class FooterTextNode extends Node<Record<string, never>> {}

View File

@@ -1,18 +1,13 @@
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 = {
/** @category Embed */
export interface EmbedImageProps {
url: string
}
/**
* @category Embed
*/
/** @category Embed */
export function EmbedImage(props: EmbedImageProps) {
return (
<ReacordElement

View File

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

View File

@@ -1,18 +1,13 @@
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 = {
/** @category Embed */
export interface EmbedThumbnailProps {
url: string
}
/**
* @category Embed
*/
/** @category Embed */
export function EmbedThumbnail(props: EmbedThumbnailProps) {
return (
<ReacordElement

View File

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

View File

@@ -1,6 +1,6 @@
import { snakeCaseDeep } from "@reacord/helpers/convert-object-property-case"
import { omit } from "@reacord/helpers/omit"
import React from "react"
import type React from "react"
import { ReacordElement } from "../../internal/element.js"
import type { MessageOptions } from "../../internal/message"
import { Node } from "../../internal/node.js"
@@ -12,7 +12,7 @@ import type { EmbedOptions } from "./embed-options"
* @category Embed
* @see https://discord.com/developers/docs/resources/channel#embed-object
*/
export type EmbedProps = {
export interface EmbedProps {
title?: string
description?: string
url?: string
@@ -53,7 +53,7 @@ class EmbedNode extends Node<EmbedProps> {
child.modifyEmbedOptions(embed)
}
if (child instanceof TextNode) {
embed.description = (embed.description || "") + child.props
embed.description = (embed.description ?? "") + child.props
}
}

View File

@@ -1,13 +1,10 @@
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import type { MessageOptions } from "../../internal/message"
import { getNextActionRow } from "../../internal/message"
import { Node } from "../../internal/node.js"
import type { ButtonSharedProps } from "./button-shared-props"
/**
* @category Link
*/
/** @category Link */
export type LinkProps = ButtonSharedProps & {
/** The URL the link should lead to */
url: string
@@ -15,14 +12,12 @@ export type LinkProps = ButtonSharedProps & {
children?: string
}
/**
* @category Link
*/
/** @category Link */
export function Link({ label, children, ...props }: LinkProps) {
return (
<ReacordElement props={props} createNode={() => new LinkNode(props)}>
<ReacordElement props={{}} createNode={() => new LinkTextNode({})}>
{label || children}
{label ?? children}
</ReacordElement>
</ReacordElement>
)
@@ -40,4 +35,4 @@ class LinkNode extends Node<Omit<LinkProps, "label" | "children">> {
}
}
class LinkTextNode extends Node<{}> {}
class LinkTextNode extends Node<Record<string, never>> {}

View File

@@ -15,5 +15,5 @@ export class OptionNode extends Node<
}
}
export class OptionLabelNode extends Node<{}> {}
export class OptionDescriptionNode extends Node<{}> {}
export class OptionLabelNode extends Node<Record<string, never>> {}
export class OptionDescriptionNode extends Node<Record<string, never>> {}

View File

@@ -1,5 +1,4 @@
import type { ReactNode } from "react"
import React from "react"
import { ReacordElement } from "../../internal/element"
import {
OptionDescriptionNode,
@@ -7,10 +6,8 @@ import {
OptionNode,
} from "./option-node"
/**
* @category Select
*/
export type OptionProps = {
/** @category Select */
export interface OptionProps {
/** The internal value of this option */
value: string
/** The text shown to the user. This takes priority over `children` */
@@ -23,19 +20,16 @@ export type OptionProps = {
/**
* 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>`.
* 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.
* To get an emoji code, type your emoji in Discord chat with a backslash `\`
* in front. The bot has to be in the emoji's guild to use it.
*/
emoji?: string
}
/**
* @category Select
*/
/** @category Select */
export function Option({
label,
children,
@@ -46,7 +40,7 @@ export function Option({
<ReacordElement props={props} createNode={() => new OptionNode(props)}>
{(label !== undefined || children !== undefined) && (
<ReacordElement props={{}} createNode={() => new OptionLabelNode({})}>
{label || children}
{label ?? children}
</ReacordElement>
)}
{description !== undefined && (

View File

@@ -1,7 +1,6 @@
import { isInstanceOf } from "@reacord/helpers/is-instance-of"
import { randomUUID } from "node:crypto"
import type { ReactNode } from "react"
import React from "react"
import { ReacordElement } from "../../internal/element.js"
import type { ComponentInteraction } from "../../internal/interaction"
import type {
@@ -12,11 +11,17 @@ import type {
import { Node } from "../../internal/node.js"
import type { ComponentEvent } from "../component-event"
import { OptionNode } from "./option-node"
import { omit } from "@reacord/helpers/omit.js"
/**
* @category Select
*/
export type SelectProps = {
export type SelectMenuType =
| "string"
| "user"
| "role"
| "mentionable"
| "channel"
/** @category Select */
export interface SelectProps {
children?: ReactNode
/** Sets the currently selected value */
value?: string
@@ -27,12 +32,26 @@ export type SelectProps = {
/** The text shown when no value is selected */
placeholder?: string
/**
* The kind of select menu to render.
*
* Defaults to `string`.
*/
menuType?: SelectMenuType
/**
* Limit the channel types shown in a channel select menu.
*
* This is only used when `menuType` is `channel`.
*/
channelTypes?: number[]
/** Set to true to allow multiple selected values */
multiple?: boolean
/**
* With `multiple`, the minimum number of values that can be selected.
* When `multiple` is false or not defined, this is always 1.
* With `multiple`, the minimum number of values that can be selected. When
* `multiple` is false or not defined, this is always 1.
*
* This only limits the number of values that can be received by the user.
* This does not limit the number of values that can be displayed by you.
@@ -40,44 +59,44 @@ export type SelectProps = {
minValues?: number
/**
* With `multiple`, the maximum number of values that can be selected.
* When `multiple` is false or not defined, this is always 1.
* With `multiple`, the maximum number of values that can be selected. When
* `multiple` is false or not defined, this is always 1.
*
* This only limits the number of values that can be received by the user.
* This does not limit the number of values that can be displayed by you.
*/
maxValues?: number
/** When true, the select will be slightly faded, and cannot be interacted with. */
/**
* When true, the select will be slightly faded, and cannot be interacted
* with.
*/
disabled?: boolean
/**
* Called when the user inputs a selection.
* Receives the entire select change event,
* which can be used to create new replies, etc.
* Called when the user inputs a selection. Receives the entire select change
* event, which can be used to create new replies, etc.
*/
onChange?: (event: SelectChangeEvent) => void
/**
* Convenience shorthand for `onChange`, which receives the first selected value.
* Convenience shorthand for `onChange`, which receives the first selected
* value.
*/
onChangeValue?: (value: string, event: SelectChangeEvent) => void
/**
* Convenience shorthand for `onChange`, which receives all selected values.
*/
/** Convenience shorthand for `onChange`, which receives all selected values. */
onChangeMultiple?: (values: string[], event: SelectChangeEvent) => void
}
/**
* @category Select
*/
/** @category Select */
export type SelectChangeEvent = ComponentEvent & {
values: string[]
}
/**
* See [the select menu guide](/guides/select-menu) for a usage example.
*
* @category Select
*/
export function Select(props: SelectProps) {
@@ -105,12 +124,13 @@ class SelectNode extends Node<SelectProps> {
values,
minValues = 0,
maxValues = 25,
children,
onChange,
onChangeValue,
onChangeMultiple,
...props
} = this.props
} = omit(this.props, [
"children",
"onChange",
"onChangeValue",
"onChangeMultiple",
])
const item: ActionRowItem = {
...props,

View File

@@ -1,10 +1,13 @@
import { raise } from "@reacord/helpers/raise"
import type { ReacordInstance } from "./instance.js"
import { raise } from "@reacord/helpers/raise.js"
import * as React from "react"
import type { ReacordInstance } from "./instance"
import type { MessageStore } from "../internal/message-store.js"
const Context = React.createContext<ReacordInstance | undefined>(undefined)
const MessageContext = React.createContext<MessageStore | undefined>(undefined)
export const InstanceProvider = Context.Provider
export const MessageProvider = MessageContext.Provider
/**
* Get the associated instance for the current component.
@@ -18,3 +21,27 @@ export function useInstance(): ReacordInstance {
raise("Could not find instance, was this component rendered via Reacord?")
)
}
/**
* Get the message that the current component is rendered into.
*
* @category Core
*/
export function useMessage() {
const store =
React.useContext(MessageContext) ??
raise("Could not find message store, was this component rendered via Reacord?")
const getSnapshot = React.useCallback(() => store.getSnapshot(), [store])
if (React.useSyncExternalStore) {
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot)
}
const [value, setValue] = React.useState(getSnapshot)
React.useEffect(() => store.subscribe(() => setValue(getSnapshot())), [
store,
getSnapshot,
])
return value
}

View File

@@ -2,11 +2,12 @@ import type { ReactNode } from "react"
/**
* Represents an interactive message, which can later be replaced or deleted.
*
* @category Core
*/
export type ReacordInstance = {
export interface ReacordInstance {
/** Render some JSX to this instance (edits the message) */
render: (content: ReactNode) => void
render: (content: ReactNode) => ReacordInstance
/** Remove this message */
destroy: () => void

View File

@@ -1,4 +1,4 @@
/* eslint-disable class-methods-use-this */
import { safeJsonStringify } from "@reacord/helpers/json"
import { pick } from "@reacord/helpers/pick"
import { pruneNullishValues } from "@reacord/helpers/prune-nullish-values"
import { raise } from "@reacord/helpers/raise"
@@ -14,11 +14,12 @@ import type {
import { ChannelMessageRenderer } from "../internal/renderers/channel-message-renderer"
import { InteractionReplyRenderer } from "../internal/renderers/interaction-reply-renderer"
import type {
ChannelInfo,
GuildInfo,
GuildMemberInfo,
MessageInfo,
UserInfo,
ComponentEventChannel,
ComponentEventGuild,
ComponentEventGuildMember,
ComponentEventMessage,
ComponentEventReplyOptions,
ComponentEventUser,
} from "./component-event"
import type { ReacordInstance } from "./instance"
import type { ReacordConfig } from "./reacord"
@@ -26,14 +27,18 @@ import { Reacord } from "./reacord"
/**
* The Reacord adapter for Discord.js.
*
* @category Core
*/
export class ReacordDiscordJs extends Reacord {
constructor(private client: Discord.Client, config: ReacordConfig = {}) {
constructor(
private client: Discord.Client,
config: ReacordConfig = {},
) {
super(config)
client.on("interactionCreate", (interaction) => {
if (interaction.isButton() || interaction.isSelectMenu()) {
if (interaction.isButton() || interaction.isAnySelectMenu()) {
this.handleComponentInteraction(
this.createReacordComponentInteraction(interaction),
)
@@ -43,59 +48,118 @@ export class ReacordDiscordJs extends Reacord {
/**
* Sends a message to a channel.
*
* @param target Discord channel object.
* @param [options] Options for the channel message
* @see https://reacord.mapleleaf.dev/guides/sending-messages
* @see {@link Discord.MessageCreateOptions}
*/
public createChannelMessage(
target: Discord.ChannelResolvable,
options: Discord.MessageCreateOptions = {},
): ReacordInstance {
return this.createInstance(
this.createChannelMessageRenderer(target, options),
)
}
/**
* Replies to a command interaction by sending a message.
*
* @param interaction Discord command interaction object.
* @param [options] Custom options for the interaction reply method.
* @see https://reacord.mapleleaf.dev/guides/sending-messages
* @see {@link Discord.InteractionReplyOptions}
*/
public createInteractionReply(
interaction: Discord.CommandInteraction,
options: Discord.InteractionReplyOptions = {},
): ReacordInstance {
return this.createInstance(
this.createInteractionReplyRenderer(interaction, options),
)
}
/**
* Sends a message to a channel.
*
* @deprecated Use reacord.createChannelMessage() instead.
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
override send(
channelId: string,
public send(
channel: Discord.ChannelResolvable,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createChannelRenderer(channelId),
this.createChannelMessageRenderer(channel, {}),
initialContent,
)
}
/**
* Sends a message as a reply to a command interaction.
*
* @deprecated Use reacord.createInteractionReply() instead.
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
override reply(
public reply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createInteractionReplyRenderer(interaction),
this.createInteractionReplyRenderer(interaction, {}),
initialContent,
)
}
/**
* Sends an ephemeral message as a reply to a command interaction.
*
* @deprecated Use reacord.createInteractionReply(interaction, { ephemeral:
* true })
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
override ephemeralReply(
public ephemeralReply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createEphemeralInteractionReplyRenderer(interaction),
this.createInteractionReplyRenderer(interaction, {
ephemeral: true,
}),
initialContent,
)
}
private createChannelRenderer(channelId: string) {
private createChannelMessageRenderer(
channelResolvable: Discord.ChannelResolvable,
messageCreateOptions?: Discord.MessageCreateOptions,
) {
return new ChannelMessageRenderer({
send: async (options) => {
const channel =
this.client.channels.cache.get(channelId) ??
(await this.client.channels.fetch(channelId)) ??
raise(`Channel ${channelId} not found`)
if (!channel.isTextBased()) {
raise(`Channel ${channelId} is not a text channel`)
send: async (messageOptions) => {
let channel = this.client.channels.resolve(channelResolvable)
if (!channel && typeof channelResolvable === "string") {
channel = await this.client.channels.fetch(channelResolvable)
}
const message = await channel.send(getDiscordMessageOptions(options))
if (!channel) {
const id =
typeof channelResolvable === "string"
? channelResolvable
: channelResolvable.id
raise(`Channel ${id} not found`)
}
if (!channel.isTextBased()) {
raise(`Channel ${channel.id} must be a text channel`)
}
const textChannel = channel as Discord.TextBasedChannel &
Discord.PartialTextBasedChannelFields
const message = await textChannel.send({
...getDiscordMessageOptions(messageOptions),
...messageCreateOptions,
})
return createReacordMessage(message)
},
})
@@ -105,48 +169,25 @@ export class ReacordDiscordJs extends Reacord {
interaction:
| Discord.CommandInteraction
| Discord.MessageComponentInteraction,
interactionReplyOptions: Discord.InteractionReplyOptions,
) {
return new InteractionReplyRenderer({
type: "command",
id: interaction.id,
reply: async (options) => {
interactionId: interaction.id,
reply: async (messageOptions) => {
const message = await interaction.reply({
...getDiscordMessageOptions(options),
...getDiscordMessageOptions(messageOptions),
...interactionReplyOptions,
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
return createReacordMessage(message)
},
followUp: async (options) => {
followUp: async (messageOptions) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
...getDiscordMessageOptions(messageOptions),
...interactionReplyOptions,
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()
return createReacordMessage(message)
},
})
}
@@ -155,7 +196,7 @@ export class ReacordDiscordJs extends Reacord {
interaction: Discord.MessageComponentInteraction,
): ComponentInteraction {
// todo please dear god clean this up
const channel: ChannelInfo = interaction.channel
const channel: ComponentEventChannel = interaction.channel
? {
...pruneNullishValues(
pick(interaction.channel, [
@@ -171,62 +212,43 @@ export class ReacordDiscordJs extends Reacord {
}
: raise("Non-channel interactions are not supported")
const message: MessageInfo =
const message: ComponentEventMessage =
interaction.message instanceof Discord.Message
? {
...pick(interaction.message, [
"id",
"channelId",
"authorId",
"content",
"tts",
"mentionEveryone",
]),
timestamp: new Date(
interaction.message.createdTimestamp,
).toISOString(),
editedTimestamp: interaction.message.editedTimestamp
? new Date(interaction.message.editedTimestamp).toISOString()
: undefined,
mentions: interaction.message.mentions.users.map((u) => u.id),
}
? createComponentEventMessage(interaction.message)
: raise("Message not found")
const member: GuildMemberInfo | undefined =
const member: ComponentEventGuildMember | undefined =
interaction.member instanceof Discord.GuildMember
? {
...pruneNullishValues(
pick(interaction.member, [
"id",
"nick",
"displayName",
"avatarUrl",
"displayAvatarUrl",
"color",
"pending",
]),
pick(interaction.member, ["nick", "avatarUrl", "pending"]),
),
id: interaction.member.id,
displayName: interaction.member.displayName,
roles: interaction.member.roles.cache.map((role) => role.id),
joinedAt: interaction.member.joinedAt?.toISOString(),
premiumSince: interaction.member.premiumSince?.toISOString(),
communicationDisabledUntil:
interaction.member.communicationDisabledUntil?.toISOString(),
color: interaction.member.displayColor,
displayAvatarUrl: interaction.member.displayAvatarURL(),
}
: undefined
const guild: GuildInfo | undefined = interaction.guild
const guild: ComponentEventGuild | undefined = interaction.guild
? {
...pruneNullishValues(pick(interaction.guild, ["id", "name"])),
id: interaction.guild.id,
name: interaction.guild.name,
member: member ?? raise("unexpected: member is undefined"),
}
: undefined
const user: UserInfo = {
...pruneNullishValues(
pick(interaction.user, ["id", "username", "discriminator", "tag"]),
),
avatarUrl: interaction.user.avatarURL()!,
const user: ComponentEventUser = {
id: interaction.user.id,
username: interaction.user.username,
discriminator: interaction.user.discriminator,
tag: interaction.user.tag,
avatarUrl: interaction.user.avatarURL(),
accentColor: interaction.user.accentColor ?? undefined,
}
@@ -234,7 +256,11 @@ export class ReacordDiscordJs extends Reacord {
id: interaction.id,
customId: interaction.customId,
update: async (options: MessageOptions) => {
if (interaction.deferred || interaction.replied) {
await interaction.message.edit(getDiscordMessageOptions(options))
} else {
await interaction.update(getDiscordMessageOptions(options))
}
},
deferUpdate: async () => {
if (interaction.replied || interaction.deferred) return
@@ -245,14 +271,14 @@ export class ReacordDiscordJs extends Reacord {
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
return createReacordMessage(message)
},
followUp: async (options) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
return createReacordMessage(message)
},
event: {
channel,
@@ -260,15 +286,18 @@ export class ReacordDiscordJs extends Reacord {
user,
guild,
reply: (content?: ReactNode) =>
reply: (content?: ReactNode, options?: ComponentEventReplyOptions) =>
this.createInstance(
this.createInteractionReplyRenderer(interaction),
this.createInteractionReplyRenderer(interaction, options ?? {}),
content,
),
/** @deprecated Use event.reply(content, { ephemeral: true }) */
ephemeralReply: (content: ReactNode) =>
this.createInstance(
this.createEphemeralInteractionReplyRenderer(interaction),
this.createInteractionReplyRenderer(interaction, {
ephemeral: true,
}),
content,
),
},
@@ -281,7 +310,7 @@ export class ReacordDiscordJs extends Reacord {
}
}
if (interaction.isSelectMenu()) {
if (interaction.isAnySelectMenu()) {
return {
...baseProps,
type: "select",
@@ -298,6 +327,7 @@ export class ReacordDiscordJs extends Reacord {
function createReacordMessage(message: Discord.Message): Message {
return {
data: createComponentEventMessage(message),
edit: async (options) => {
await message.edit(getDiscordMessageOptions(options))
},
@@ -307,16 +337,25 @@ function createReacordMessage(message: Discord.Message): Message {
}
}
function createEphemeralReacordMessage(): Message {
function createComponentEventMessage(
message: Discord.Message,
): ComponentEventMessage {
return {
edit: () => {
console.warn("Ephemeral messages can't be edited")
return Promise.resolve()
},
delete: () => {
console.warn("Ephemeral messages can't be deleted")
return Promise.resolve()
},
...pick(message, [
"id",
"channelId",
"authorId",
"content",
"tts",
"mentionEveryone",
]),
timestamp: new Date(message.createdTimestamp).toISOString(),
editedTimestamp: message.editedTimestamp
? new Date(message.editedTimestamp).toISOString()
: undefined,
mentions: message.mentions.users.map((u) => u.id),
authorId: message.author.id,
mentionEveryone: message.mentions.everyone,
}
}
@@ -335,8 +374,7 @@ function convertButtonStyleToEnum(style: MessageButtonOptions["style"]) {
// and also handle some edge cases, e.g. empty messages
function getDiscordMessageOptions(reacordOptions: MessageOptions) {
const options = {
// eslint-disable-next-line unicorn/no-null
content: reacordOptions.content || null,
content: reacordOptions.content || undefined,
embeds: reacordOptions.embeds,
components: reacordOptions.actionRows.map((row) => ({
type: Discord.ComponentType.ActionRow,
@@ -364,24 +402,73 @@ function getDiscordMessageOptions(reacordOptions: MessageOptions) {
}
}
// future proofing
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (component.type === "select") {
const {
menuType,
values,
options: selectOptions,
channelTypes,
multiple,
...rest
} = component
if (menuType === "string" || menuType == undefined) {
return {
...component,
type: Discord.ComponentType.SelectMenu,
options: component.options.map((option) => ({
...rest,
type: Discord.ComponentType.StringSelect,
options: selectOptions.map((option) => ({
...option,
default: component.values?.includes(option.value),
default: values?.includes(option.value),
})),
}
}
raise(`Unsupported component type: ${(component as any).type}`)
if (menuType === "user") {
return {
...rest,
type: Discord.ComponentType.UserSelect,
}
}
if (menuType === "role") {
return {
...rest,
type: Discord.ComponentType.RoleSelect,
}
}
if (menuType === "mentionable") {
return {
...rest,
type: Discord.ComponentType.MentionableSelect,
}
}
if (menuType === "channel") {
return {
...rest,
type: Discord.ComponentType.ChannelSelect,
channelTypes,
}
}
raise(
`Unsupported select menu type: ${menuType ?? "string"}`,
)
}
component satisfies never
throw new Error(
`Invalid component type ${safeJsonStringify(component)}}`,
)
},
),
})),
}
if (!options.content && !options.embeds?.length) {
if (!options.content && !options.embeds.length) {
options.content = "_ _"
}

View File

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

View File

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

View File

@@ -25,7 +25,9 @@ export class Container<T> {
return this.items.find(predicate)
}
findType<U extends T>(type: new (...args: any[]) => U): U | undefined {
findType<U extends T>(
type: new (...args: Array<NonNullable<unknown>>) => U,
): U | undefined {
for (const item of this.items) {
if (item instanceof type) return item
}

View File

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

View File

@@ -17,7 +17,7 @@ export type SelectInteraction = BaseComponentInteraction<
SelectChangeEvent
>
export type BaseInteraction<Type extends string> = {
export interface BaseInteraction<Type extends string> {
type: Type
id: string
reply(messageOptions: MessageOptions): Promise<Message>

View File

@@ -0,0 +1,22 @@
import type { ComponentEventMessage } from "../core/component-event"
export class MessageStore {
private value: ComponentEventMessage | undefined
private listeners = new Set<() => void>()
getSnapshot = () => this.value
subscribe = (listener: () => void) => {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
set(value: ComponentEventMessage | undefined) {
this.value = value
for (const listener of this.listeners) {
listener()
}
}
}

View File

@@ -1,9 +1,10 @@
import { last } from "@reacord/helpers/last"
import type { Except } from "type-fest"
import type { ComponentEventMessage } from "../core/component-event"
import type { EmbedOptions } from "../core/components/embed-options"
import type { SelectProps } from "../core/components/select"
import { last } from "@reacord/helpers/last"
import type { Except } from "type-fest"
export type MessageOptions = {
export interface MessageOptions {
content: string
embeds: EmbedOptions[]
actionRows: ActionRow[]
@@ -16,7 +17,7 @@ export type ActionRowItem =
| MessageLinkOptions
| MessageSelectOptions
export type MessageButtonOptions = {
export interface MessageButtonOptions {
type: "button"
customId: string
label?: string
@@ -25,7 +26,7 @@ export type MessageButtonOptions = {
emoji?: string
}
export type MessageLinkOptions = {
export interface MessageLinkOptions {
type: "link"
url: string
label?: string
@@ -39,14 +40,15 @@ export type MessageSelectOptions = Except<SelectProps, "children" | "value"> & {
options: MessageSelectOptionOptions[]
}
export type MessageSelectOptionOptions = {
export interface MessageSelectOptionOptions {
label: string
value: string
description?: string
emoji?: string
}
export type Message = {
export interface Message {
data?: ComponentEventMessage
edit(options: MessageOptions): Promise<void>
delete(): Promise<void>
}

View File

@@ -1,4 +1,3 @@
/* eslint-disable class-methods-use-this */
import { Container } from "./container.js"
import type { ComponentInteraction } from "./interaction"
import type { MessageOptions } from "./message"
@@ -8,9 +7,11 @@ export abstract class Node<Props> {
constructor(public props: Props) {}
modifyMessageOptions(options: MessageOptions) {}
modifyMessageOptions(_options: MessageOptions) {
// noop
}
handleComponentInteraction(interaction: ComponentInteraction): boolean {
handleComponentInteraction(_interaction: ComponentInteraction): boolean {
return false
}

View File

@@ -15,7 +15,7 @@ const config: HostConfig<
never, // SuspenseInstance,
never, // HydratableInstance,
never, // PublicInstance,
never, // HostContext,
null, // HostContext,
true, // UpdatePayload,
never, // ChildSet,
number, // TimeoutHandle,
@@ -29,7 +29,6 @@ const config: HostConfig<
cancelTimeout: global.clearTimeout,
noTimeout: -1,
// eslint-disable-next-line unicorn/no-null
getRootHostContext: () => null,
getChildHostContext: (parentContext) => parentContext,
@@ -42,7 +41,7 @@ const config: HostConfig<
raise(`Missing createNode function`)
}
const node = props.createNode(props.props)
const node: unknown = props.createNode(props.props)
if (!(node instanceof Node)) {
raise(`createNode function did not return a Node`)
}
@@ -51,13 +50,11 @@ const config: HostConfig<
},
createTextInstance: (text) => new TextNode(text),
shouldSetTextContent: () => false,
detachDeletedInstance: (instance) => {},
detachDeletedInstance: (_instance) => {},
beforeActiveInstanceBlur: () => {},
afterActiveInstanceBlur: () => {},
// eslint-disable-next-line unicorn/no-null
getInstanceFromNode: (node: any) => null,
// eslint-disable-next-line unicorn/no-null
getInstanceFromScope: (scopeInstance: any) => null,
getInstanceFromNode: (_node: unknown) => null,
getInstanceFromScope: (_scopeInstance: unknown) => null,
clearContainer: (renderer) => {
renderer.nodes.clear()
@@ -93,12 +90,11 @@ const config: HostConfig<
node.props = newText
},
// eslint-disable-next-line unicorn/no-null
prepareForCommit: () => null,
resetAfterCommit: (renderer) => {
renderer.render()
},
prepareScopeUpdate: (scopeInstance: any, instance: any) => {},
prepareScopeUpdate: (_scopeInstance: unknown, _instance: unknown) => {},
preparePortalMount: () => raise("Portals are not supported"),
getPublicInstance: () => raise("Refs are currently not supported"),

View File

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

View File

@@ -1,9 +1,10 @@
import { Subject } from "rxjs"
import { concatMap } from "rxjs/operators"
import { Container } from "../container.js"
import type { ComponentInteraction } from "../interaction"
import { MessageStore } from "../message-store.js"
import type { Message, MessageOptions } from "../message"
import type { Node } from "../node.js"
import { Subject } from "rxjs"
import { concatMap } from "rxjs/operators"
type UpdatePayload =
| { action: "update" | "deactivate"; options: MessageOptions }
@@ -12,6 +13,7 @@ type UpdatePayload =
export abstract class Renderer {
readonly nodes = new Container<Node<unknown>>()
readonly messageStore = new MessageStore()
private componentInteraction?: ComponentInteraction
private message?: Message
private active = true
@@ -47,14 +49,12 @@ export abstract class Renderer {
}
handleComponentInteraction(interaction: ComponentInteraction) {
for (const node of this.nodes) {
if (node.handleComponentInteraction(interaction)) {
this.componentInteraction = interaction
setTimeout(() => {
this.updates.next({ action: "deferUpdate", interaction })
}, 500)
for (const node of this.nodes) {
if (node.handleComponentInteraction(interaction)) {
return true
}
}
@@ -77,6 +77,7 @@ export abstract class Renderer {
private async updateMessage(payload: UpdatePayload) {
if (payload.action === "destroy") {
this.updateSubscription.unsubscribe()
this.messageStore.set(undefined)
await this.message?.delete()
return
}
@@ -115,5 +116,6 @@ export abstract class Renderer {
}
this.message = await this.createMessage(payload.options)
this.messageStore.set(this.message.data)
}
}

View File

@@ -13,6 +13,6 @@ 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 { useInstance, useMessage } from "./core/instance-context"
export * from "./core/reacord"
export * from "./core/reacord-discord-js"

View File

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

View File

@@ -1,26 +1,32 @@
import type { TextChannel } from "discord.js"
import { ChannelType, Client, IntentsBitField } from "discord.js"
import "dotenv/config"
import { kebabCase } from "lodash-es"
import * as React from "react"
import { useState } from "react"
import { raise } from "@reacord/helpers/raise.js"
import {
Button,
Embed,
EmbedField,
Link,
Option,
ReacordDiscordJs,
Select,
useInstance,
} from "../library/main"
} from "../library/main.js"
import type { TextChannel } from "discord.js"
import { ChannelType, Client, IntentsBitField } from "discord.js"
import "dotenv/config"
import { kebabCase } from "lodash-es"
import { useState } from "react"
const client = new Client({ intents: IntentsBitField.Flags.Guilds })
const reacord = new ReacordDiscordJs(client)
await client.login(process.env.TEST_BOT_TOKEN)
const guild = await client.guilds.fetch(process.env.TEST_GUILD_ID!)
const guild = await client.guilds.fetch(
process.env.TEST_GUILD_ID ?? raise("TEST_GUILD_ID not defined"),
)
const category = await guild.channels.fetch(process.env.TEST_CATEGORY_ID!)
const category = await guild.channels.fetch(
process.env.TEST_CATEGORY_ID ?? raise("TEST_CATEGORY_ID not defined"),
)
if (category?.type !== ChannelType.GuildCategory) {
throw new Error(
`channel ${process.env.TEST_CATEGORY_ID} is not a guild category. received ${category?.type}`,
@@ -34,7 +40,7 @@ for (const [, channel] of category.children.cache) {
let prefix = 0
const createTest = async (
name: string,
block: (channel: TextChannel) => void | Promise<unknown>,
block: (channel: TextChannel) => unknown,
) => {
prefix += 1
const channel = await category.children.create({
@@ -45,12 +51,60 @@ const createTest = async (
}
await createTest("basic", (channel) => {
reacord.send(channel.id, "Hello, world!")
reacord.createChannelMessage(channel).render("Hello, world!")
})
await createTest("readme counter", (channel) => {
interface EmbedCounterProps {
count: number
visible: boolean
}
function EmbedCounter({ count, visible }: EmbedCounterProps) {
if (!visible) return <></>
return (
<Embed title="the counter">
<EmbedField name="is it even?">{count % 2 ? "no" : "yes"}</EmbedField>
</Embed>
)
}
function Counter() {
const [showEmbed, setShowEmbed] = useState(false)
const [count, setCount] = useState(0)
const instance = useInstance()
return (
<>
this button was clicked {count} times
<EmbedCounter count={count} visible={showEmbed} />
<Button
style="primary"
label="clicc"
onClick={() => setCount(count + 1)}
/>
<Button
style="secondary"
label={showEmbed ? "hide embed" : "show embed"}
onClick={() => setShowEmbed(!showEmbed)}
/>
<Button
style="danger"
label="deactivate"
onClick={() => instance.destroy()}
/>
</>
)
}
reacord.createChannelMessage(channel).render(<Counter />)
})
await createTest("counter", (channel) => {
const Counter = () => {
const [count, setCount] = React.useState(0)
function Counter() {
const [count, setCount] = useState(0)
return (
<>
count: {count}
@@ -68,7 +122,7 @@ await createTest("counter", (channel) => {
</>
)
}
reacord.send(channel.id, <Counter />)
reacord.createChannelMessage(channel).render(<Counter />)
})
await createTest("select", (channel) => {
@@ -97,8 +151,7 @@ await createTest("select", (channel) => {
)
}
const instance = reacord.send(
channel.id,
const instance = reacord.createChannelMessage(channel).render(
<FruitSelect
onConfirm={(value) => {
instance.render(`you chose ${value}`)
@@ -109,8 +162,7 @@ await createTest("select", (channel) => {
})
await createTest("ephemeral button", (channel) => {
reacord.send(
channel.id,
reacord.createChannelMessage(channel).render(
<>
<Button
label="public clic"
@@ -120,7 +172,7 @@ await createTest("ephemeral button", (channel) => {
/>
<Button
label="clic"
onClick={(event) => event.ephemeralReply("you clic")}
onClick={(event) => event.reply("you clic", { ephemeral: true })}
/>
</>,
)
@@ -131,9 +183,11 @@ await createTest("delete this", (channel) => {
const instance = useInstance()
return <Button label="delete this" onClick={() => instance.destroy()} />
}
reacord.send(channel.id, <DeleteThis />)
reacord.createChannelMessage(channel).render(<DeleteThis />)
})
await createTest("link", (channel) => {
reacord.send(channel.id, <Link label="hi" url="https://mapleleaf.dev" />)
reacord
.createChannelMessage(channel)
.render(<Link label="hi" url="https://mapleleaf.dev" />)
})

View File

@@ -1,4 +1,3 @@
import React from "react"
import { test } from "vitest"
import { ActionRow, Button, Select } from "../library/main"
import { ReacordTester } from "./test-adapter"

View File

@@ -1,12 +1,15 @@
import { spawnSync } from "node:child_process"
import { fileURLToPath } from "node:url"
import { dirname } from "node:path"
import { createRequire } from "node:module"
import { beforeAll, expect, test } from "vitest"
beforeAll(() => {
spawnSync("pnpm", ["run", "build"])
const cwd = dirname(dirname(fileURLToPath(import.meta.url)))
spawnSync("bun", ["run", "build"], { cwd })
})
test("can require commonjs", () => {
const require = createRequire(import.meta.url)
expect(() => require("../dist/main.cjs")).not.toThrow()
expect(() => require("../dist/main.cjs") as unknown).not.toThrow()
})

View File

@@ -1,4 +1,3 @@
import React from "react"
import { test } from "vitest"
import {
Embed,

View File

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

View File

@@ -1,4 +1,3 @@
import React from "react"
import { test } from "vitest"
import { Link } from "../library/main"
import { ReacordTester } from "./test-adapter"

View File

@@ -1,13 +1,14 @@
import * as React from "react"
import { test } from "vitest"
import { Button, Embed, EmbedField, EmbedTitle } from "../library/main"
import { ReacordTester } from "./test-adapter"
import * as React from "react"
import { test } from "vitest"
test("rendering behavior", async () => {
const tester = new ReacordTester()
const reply = tester.reply()
reply.render(<KitchenSinkCounter onDeactivate={() => reply.deactivate()} />)
const reply = tester
.createInteractionReply()
.render(<KitchenSinkCounter onDeactivate={() => reply.deactivate()} />)
await tester.assertMessages([
{
@@ -244,8 +245,7 @@ test("rendering behavior", async () => {
test("delete", async () => {
const tester = new ReacordTester()
const reply = tester.reply()
reply.render(
const reply = tester.createInteractionReply().render(
<>
some text
<Embed>some embed</Embed>

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react"
import { useState } from "react"
import { expect, test, vi } from "vitest"
import { Button, Option, Select } from "../library/main"
import { ReacordTester } from "./test-adapter"
@@ -53,9 +53,7 @@ test("single select", async () => {
])
}
const reply = tester.reply()
reply.render(<TestSelect />)
tester.createInteractionReply().render(<TestSelect />)
await assertSelect([])
expect(onSelect).toHaveBeenCalledTimes(0)
@@ -119,22 +117,24 @@ test("multiple select", async () => {
])
}
const reply = tester.reply()
reply.render(<TestSelect />)
tester.createInteractionReply().render(<TestSelect />)
await assertSelect([])
expect(onSelect).toHaveBeenCalledTimes(0)
await tester.findSelectByPlaceholder("select").select("1", "3")
await assertSelect(expect.arrayContaining(["1", "3"]) as unknown as string[])
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ values: expect.arrayContaining(["1", "3"]) }),
expect.objectContaining({
values: expect.arrayContaining(["1", "3"]) as unknown,
}),
)
await tester.findSelectByPlaceholder("select").select("2")
await assertSelect(expect.arrayContaining(["2"]) as unknown as string[])
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ values: expect.arrayContaining(["2"]) }),
expect.objectContaining({
values: expect.arrayContaining(["2"]) as unknown,
}),
)
await tester.findSelectByPlaceholder("select").select()
@@ -144,7 +144,7 @@ test("multiple select", async () => {
test("optional onSelect + unknown value", async () => {
const tester = new ReacordTester()
tester.reply().render(<Select placeholder="select" />)
tester.createInteractionReply().render(<Select placeholder="select" />)
await tester.findSelectByPlaceholder("select").select("something")
await tester.assertMessages([
{

View File

@@ -1,5 +1,3 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable require-await */
import { logPretty } from "@reacord/helpers/log-pretty"
import { omit } from "@reacord/helpers/omit"
import { pruneNullishValues } from "@reacord/helpers/prune-nullish-values"
@@ -10,10 +8,11 @@ import { setTimeout } from "node:timers/promises"
import type { ReactNode } from "react"
import { expect } from "vitest"
import type {
ChannelInfo,
GuildInfo,
MessageInfo,
UserInfo,
ComponentEventChannel,
ComponentEventGuild,
ComponentEventMessage,
ComponentEventReplyOptions,
ComponentEventUser,
} from "../library/core/component-event"
import type { ButtonClickEvent } from "../library/core/components/button"
import type { SelectChangeEvent } from "../library/core/components/select"
@@ -23,18 +22,18 @@ import type { Channel } from "../library/internal/channel"
import { Container } from "../library/internal/container"
import type {
ButtonInteraction,
CommandInteraction,
SelectInteraction,
} from "../library/internal/interaction"
import type { Message, MessageOptions } from "../library/internal/message"
import { ChannelMessageRenderer } from "../library/internal/renderers/channel-message-renderer"
import { InteractionReplyRenderer } from "../library/internal/renderers/interaction-reply-renderer"
import {
InteractionReplyRenderer,
type InteractionReplyRendererImplementation,
} from "../library/internal/renderers/interaction-reply-renderer"
export type MessageSample = ReturnType<ReacordTester["sampleMessages"]>[0]
/**
* A Record adapter for automated tests. WIP
*/
/** A Record adapter for automated tests. WIP */
export class ReacordTester extends Reacord {
private messageContainer = new Container<TestMessage>()
@@ -46,26 +45,28 @@ export class ReacordTester extends Reacord {
return [...this.messageContainer]
}
override send(initialContent?: ReactNode): ReacordInstance {
public createChannelMessage(): ReacordInstance {
return this.createInstance(
new ChannelMessageRenderer(new TestChannel(this.messageContainer)),
initialContent,
)
}
override reply(initialContent?: ReactNode): ReacordInstance {
public createMessageReply(): ReacordInstance {
return this.createInstance(
new ChannelMessageRenderer(new TestChannel(this.messageContainer)),
)
}
public createInteractionReply(
_options?: ComponentEventReplyOptions,
): ReacordInstance {
return this.createInstance(
new InteractionReplyRenderer(
new TestCommandInteraction(this.messageContainer),
),
initialContent,
)
}
override ephemeralReply(initialContent?: ReactNode): ReacordInstance {
return this.reply(initialContent)
}
assertMessages(expected: MessageSample[]) {
return waitFor(() => {
expect(this.sampleMessages()).toEqual(expected)
@@ -73,7 +74,7 @@ export class ReacordTester extends Reacord {
}
async assertRender(content: ReactNode, expected: MessageSample[]) {
const instance = this.reply()
const instance = this.createInteractionReply()
instance.render(content)
await this.assertMessages(expected)
instance.destroy()
@@ -175,9 +176,8 @@ class TestMessage implements Message {
}
}
class TestCommandInteraction implements CommandInteraction {
readonly type = "command"
readonly id = "test-command-interaction"
class TestCommandInteraction implements InteractionReplyRendererImplementation {
readonly interactionId = "test-command-interaction"
readonly channelId = "test-channel-id"
constructor(private messageContainer: Container<TestMessage>) {}
@@ -252,17 +252,19 @@ class TestSelectInteraction
class TestComponentEvent {
constructor(private tester: ReacordTester) {}
message: MessageInfo = {} as any // todo
channel: ChannelInfo = {} as any // todo
user: UserInfo = {} as any // todo
guild: GuildInfo = {} as any // todo
message: ComponentEventMessage = {} as ComponentEventMessage // todo
channel: ComponentEventChannel = {} as ComponentEventChannel // todo
user: ComponentEventUser = {} as ComponentEventUser // todo
guild: ComponentEventGuild = {} as ComponentEventGuild // todo
reply(content?: ReactNode): ReacordInstance {
return this.tester.reply(content)
return this.tester.createInteractionReply().render(content)
}
ephemeralReply(content?: ReactNode): ReacordInstance {
return this.tester.ephemeralReply(content)
return this.tester
.createInteractionReply({ ephemeral: true })
.render(content)
}
}
@@ -274,7 +276,10 @@ class TestSelectChangeEvent
extends TestComponentEvent
implements SelectChangeEvent
{
constructor(readonly values: string[], tester: ReacordTester) {
constructor(
readonly values: string[],
tester: ReacordTester,
) {
super(tester)
}
}

View File

@@ -1,4 +1,3 @@
import * as React from "react"
import { test } from "vitest"
import {
Button,

View File

@@ -1,9 +1,8 @@
import React from "react"
import { describe, expect, it } from "vitest"
import type { ReacordInstance } from "../library/main"
import { Button, useInstance } from "../library/main"
import type { MessageSample } from "./test-adapter"
import { ReacordTester } from "./test-adapter"
import { describe, expect, it } from "vitest"
describe("useInstance", () => {
it("returns the instance of itself", async () => {
@@ -50,7 +49,9 @@ describe("useInstance", () => {
}
const tester = new ReacordTester()
const instance = tester.send(<TestComponent name="parent" />)
const instance = tester
.createChannelMessage()
.render(<TestComponent name="parent" />)
await tester.assertMessages([messageOutput("parent")])
expect(instanceFromHook).toBe(instance)

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"declarationMap": true,
"outDir": "dist"
}
}

View File

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

View File

@@ -1,11 +0,0 @@
node_modules
/.cache
/build
/public/build
.env
/public/api
cypress/videos
cypress/screenshots
*.out.css
/api
.astro

View File

@@ -1,24 +0,0 @@
# website
## 0.4.3
### Patch Changes
- Updated dependencies [9813a01]
- reacord@0.5.2
## 0.4.2
### Patch Changes
- Updated dependencies [72f4a4a]
- Updated dependencies [7536bde]
- Updated dependencies [e335165]
- reacord@0.5.1
## 0.4.1
### Patch Changes
- Updated dependencies [aa65da5]
- reacord@0.5.0

View File

@@ -1,17 +0,0 @@
import prefetch from "@astrojs/prefetch"
import react from "@astrojs/react"
import tailwind from "@astrojs/tailwind"
import { defineConfig } from "astro/config"
// https://astro.build/config
export default defineConfig({
integrations: [
tailwind({
config: {
applyBaseStyles: false,
},
}),
react(),
prefetch(),
],
})

View File

@@ -1,42 +0,0 @@
{
"type": "module",
"name": "website",
"version": "0.4.3",
"private": true,
"sideEffects": false,
"scripts": {
"dev": "run-p --race --print-label dev:*",
"dev:typedoc": "typedoc --watch",
"dev:astro": "astro dev",
"test": "node ./scripts/test.js",
"test-dev": "run-p --race --print-label dev:* test-dev:*",
"test-dev:cypress": "wait-on http-get://localhost:3000 && cypress open",
"start": "astro preview",
"build": "typedoc && astro build",
"typecheck": "tsc --noEmit && tsc --project cypress/tsconfig.json --noEmit"
},
"dependencies": {
"@astrojs/prefetch": "^0.2.0",
"@astrojs/react": "^2.1.0",
"@fontsource/jetbrains-mono": "^4.5.12",
"@fontsource/rubik": "^4.5.14",
"@heroicons/react": "^2.0.16",
"@tailwindcss/typography": "^0.5.9",
"astro": "^2.1.2",
"clsx": "^1.2.1",
"reacord": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@astrojs/tailwind": "^3.1.0",
"@types/node": "*",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"npm-run-all": "^4.1.5",
"tailwindcss": "^3.2.7",
"typedoc": "^0.23.26",
"typescript": "^4.9.5",
"wait-on": "^7.0.1"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,3 +0,0 @@
<svg width="53" height="35" viewBox="0 0 53 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="3" cy="3" r="1" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 658 B

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,52 +0,0 @@
---
title: Getting Started
description: Learn how to get started with Reacord.
slug: getting-started
---
# Getting Started
These guides assume some familiarity with JavaScript, [React](https://reactjs.org), [Discord.js](https://discord.js.org) and the [Discord API](https://discord.dev). Keep these pages as reference if you need it.
## Setup from template
[Use this starter template](https://github.com/itsMapleLeaf/reacord-starter) to get off the ground quickly.
## Adding to an existing project
Install Reacord and dependencies:
```bash
# npm
npm install reacord react discord.js
# yarn
yarn add reacord react discord.js
# pnpm
pnpm add reacord react discord.js
```
Create a Discord.js client and a Reacord instance:
```js
// main.jsx
import { Client } from "discord.js"
import { ReacordDiscordJs } from "reacord"
const client = new Client()
const reacord = new ReacordDiscordJs(client)
client.on("ready", () => {
console.log("Ready!")
})
await client.login(process.env.BOT_TOKEN)
```
To use JSX in your code, run it with [tsx](https://npm.im/tsx):
```bash
npm install tsx
tsx main.tsx
```

View File

@@ -1,166 +0,0 @@
---
title: Sending Messages
description: Sending messages by creating Reacord instances
slug: sending-messages
---
# Sending Messages with Instances
You can send messages via Reacord to a channel like so.
```jsx
const channelId = "abc123deadbeef"
client.on("ready", () => {
reacord.send(channelId, "Hello, world!")
})
```
The `.send()` function creates a **Reacord instance**. You can pass strings, numbers, or anything that can be rendered by React, such as JSX!
Components rendered through this instance can include state and effects, and the message on Discord will update automatically.
```jsx
function Uptime() {
const [startTime] = useState(Date.now())
const [currentTime, setCurrentTime] = useState(Date.now())
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(Date.now())
}, 3000)
return () => clearInterval(interval)
}, [])
return <>this message has been shown for {currentTime - startTime}ms</>
}
client.on("ready", () => {
reacord.send(channelId, <Uptime />)
})
```
The instance can be rendered to multiple times, which will update the message each time.
```jsx
const Hello = ({ subject }) => <>Hello, {subject}!</>
client.on("ready", () => {
const instance = reacord.send(channel)
instance.render(<Hello subject="World" />)
instance.render(<Hello subject="Moon" />)
})
```
## Cleaning Up Instances
If you no longer want to use the instance, you can clean it up in a few ways:
- `instance.destroy()` - This will remove the message.
- `instance.deactivate()` - This will keep the message, but it will disable the components on the message, and no longer listen to user interactions.
By default, Reacord has a max limit on the number of active instances, and deactivates older instances to conserve memory. This can be configured through the Reacord options:
```js
const reacord = new ReacordDiscordJs(client, {
// after sending four messages,
// the first one will be deactivated
maxInstances: 3,
})
```
## Discord Slash Commands
<aside>
This section also applies to other kinds of application commands, such as context menu commands.
</aside>
To reply to a command interaction, use the `.reply()` function. This function returns an instance that works the same way as the one from `.send()`. Here's an example:
```jsx
import { Client } from "discord.js"
import * as React from "react"
import { Button, ReacordDiscordJs } from "reacord"
const client = new Client({ intents: [] })
const reacord = new ReacordDiscordJs(client)
client.on("ready", () => {
client.application?.commands.create({
name: "ping",
description: "pong!",
})
})
client.on("interactionCreate", (interaction) => {
if (interaction.isCommand() && interaction.commandName === "ping") {
// Use the reply() function instead of send
reacord.reply(interaction, <>pong!</>)
}
})
client.login(process.env.DISCORD_TOKEN)
```
<aside>
This example uses <a href="https://discord.com/developers/docs/interactions/application-commands#registering-a-command">global commands</a>, so the command might take a while to show up 😅
</aside>
However, the process of creating commands can get really repetitive and error-prone. A command framework could help with this, or you could make a small helper:
```jsx
function handleCommands(client, commands) {
client.on("ready", () => {
for (const { name, description } of commands) {
client.application?.commands.create({ name, description })
}
})
client.on("interactionCreate", (interaction) => {
if (interaction.isCommand()) {
for (const command of commands) {
if (interaction.commandName === command.name) {
command.run(interaction)
}
}
}
})
}
```
```jsx
handleCommands(client, [
{
name: "ping",
description: "pong!",
run: (interaction) => {
reacord.reply(interaction, <>pong!</>)
},
},
{
name: "hi",
description: "say hi",
run: (interaction) => {
reacord.reply(interaction, <>hi</>)
},
},
])
```
## Ephemeral Command Replies
Ephemeral replies are replies that only appear for one user. To create them, use the `.ephemeralReply()` function.
```tsx
handleCommands(client, [
{
name: "pong",
description: "pong, but in secret",
run: (interaction) => {
reacord.ephemeralReply(interaction, <>(pong)</>)
},
},
])
```
The `ephemeralReply` function also returns an instance, but ephemeral replies cannot be updated via `instance.render()`. You can `.deactivate()` them, but `.destroy()` will not delete the message; only the user can hide it from view.

View File

@@ -1,63 +0,0 @@
---
title: Embeds
description: Using embed components
slug: embeds
---
# Embeds
Reacord comes with an `<Embed />` component for sending rich embeds.
```jsx
import { Embed } from "reacord"
function FancyMessage({ title, description }) {
return (
<Embed
title={title}
description={description}
color={0x00ff00}
timestamp={Date.now()}
/>
)
}
```
```jsx
reacord.send(channelId, <FancyMessage title="Hello" description="World" />)
```
Reacord also comes with multiple embed components, for defining embeds on a piece-by-piece basis. This enables composition:
```jsx
import { Embed, EmbedTitle } from "reacord"
function FancyDetails({ title, description }) {
return (
<>
<EmbedTitle>{title}</EmbedTitle>
{/* embed descriptions are just text */}
{description}
</>
)
}
function FancyMessage({ children }) {
return (
<Embed color={0x00ff00} timestamp={Date.now()}>
{children}
</Embed>
)
}
```
```jsx
reacord.send(
channelId,
<FancyMessage>
<FancyDetails title="Hello" description="World" />
</FancyMessage>,
)
```
See the [API Reference](/api/index.html#EmbedAuthorProps) for the full list of embed components.

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