diff --git a/integration/rendering.test.tsx b/integration/rendering.test.tsx index 4335f21..170e940 100644 --- a/integration/rendering.test.tsx +++ b/integration/rendering.test.tsx @@ -5,7 +5,7 @@ import React from "react" import { omit } from "../src/helpers/omit.js" import { raise } from "../src/helpers/raise.js" import type { ReacordRoot } from "../src/main.js" -import { createRoot, Embed, EmbedField, Text } from "../src/main.js" +import { Button, createRoot, Embed, EmbedField, Text } from "../src/main.js" import { testBotToken, testChannelId } from "./test-environment.js" const client = new Client({ @@ -112,6 +112,15 @@ test("kitchen sink", async () => { field content but inline + + + + + + + , ) await assertMessages([ @@ -150,6 +159,60 @@ test("kitchen sink", async () => { ], }, ], + components: [ + { + type: "ACTION_ROW", + components: [ + { + type: "BUTTON", + label: "primary button", + style: "PRIMARY", + disabled: false, + }, + { + type: "BUTTON", + label: "danger button", + style: "DANGER", + disabled: false, + }, + { + type: "BUTTON", + label: "success button", + style: "SUCCESS", + disabled: false, + }, + { + type: "BUTTON", + label: "secondary button", + style: "SECONDARY", + disabled: false, + }, + { + type: "BUTTON", + label: "secondary by default", + style: "SECONDARY", + disabled: false, + }, + ], + }, + { + type: "ACTION_ROW", + components: [ + { + type: "BUTTON", + label: "complex button text", + style: "SECONDARY", + disabled: false, + }, + { + type: "BUTTON", + label: "disabled button", + style: "SECONDARY", + disabled: true, + }, + ], + }, + ], }, ]) }) @@ -171,7 +234,7 @@ function extractMessageData(message: Message): MessageOptions { return { content: nonEmptyOrUndefined(message.content), embeds: nonEmptyOrUndefined( - pruneUndefinedKeys( + pruneUndefinedValues( message.embeds.map((embed) => ({ title: embed.title ?? undefined, description: embed.description ?? undefined, @@ -191,10 +254,40 @@ function extractMessageData(message: Message): MessageOptions { })), ), ), + components: nonEmptyOrUndefined( + message.components.map((row) => ({ + type: "ACTION_ROW", + components: row.components.map((component) => { + if (component.type === "BUTTON") { + return pruneUndefinedValues({ + type: "BUTTON", + style: component.style ?? "SECONDARY", + label: component.label ?? undefined, + emoji: component.emoji?.name, + url: component.url ?? undefined, + disabled: component.disabled ?? undefined, + }) + } + + if (component.type === "SELECT_MENU") { + return pruneUndefinedValues({ + type: "SELECT_MENU", + disabled: component.disabled ?? undefined, + options: component.options.map((option) => ({ + label: option.label ?? undefined, + value: option.value ?? undefined, + })), + }) + } + + raise(`unknown component type ${(component as any).type}`) + }), + })), + ), } } -function pruneUndefinedKeys(input: T) { +function pruneUndefinedValues(input: T) { return JSON.parse(JSON.stringify(input)) } diff --git a/notes.md b/notes.md index 5a0818c..5717ea0 100644 --- a/notes.md +++ b/notes.md @@ -12,10 +12,11 @@ - [x] image - url - [x] fields - name, value, inline - message components - - [ ] buttons + - [x] buttons - [ ] links - [ ] select - [ ] action row + - [ ] button onClick # cool ideas / polish diff --git a/package.json b/package.json index 0645004..b481c59 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@types/react": "*", "@types/react-reconciler": "^0.26.4", "immer": "^9.0.7", + "nanoid": "^3.1.30", "react-reconciler": "^0.26.2" }, "peerDependencies": { @@ -38,8 +39,6 @@ "@types/jest": "^27.0.3", "@typescript-eslint/eslint-plugin": "^5.8.0", "@typescript-eslint/parser": "^5.8.0", - "c8": "^7.10.0", - "chai": "^4.3.4", "discord.js": "^13.3.1", "dotenv": "^10.0.0", "esbuild": "^0.14.6", @@ -53,10 +52,8 @@ "eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-unicorn": "^39.0.0", "jest": "^27.4.5", - "nanoid": "^3.1.30", "prettier": "^2.5.1", "react": "^17.0.2", - "should": "^13.2.3", "tsup": "^5.11.6", "typescript": "^4.5.4" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e5a1df..89b10d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,8 +11,6 @@ importers: '@types/react-reconciler': ^0.26.4 '@typescript-eslint/eslint-plugin': ^5.8.0 '@typescript-eslint/parser': ^5.8.0 - c8: ^7.10.0 - chai: ^4.3.4 discord.js: ^13.3.1 dotenv: ^10.0.0 esbuild: ^0.14.6 @@ -31,7 +29,6 @@ importers: prettier: ^2.5.1 react: ^17.0.2 react-reconciler: ^0.26.2 - should: ^13.2.3 tsup: ^5.11.6 typescript: ^4.5.4 dependencies: @@ -39,14 +36,13 @@ importers: '@types/react': 17.0.37 '@types/react-reconciler': 0.26.4 immer: 9.0.7 + nanoid: 3.1.30 react-reconciler: 0.26.2_react@17.0.2 devDependencies: '@itsmapleleaf/configs': 1.1.2 '@types/jest': 27.0.3 '@typescript-eslint/eslint-plugin': 5.8.0_836011a006f4f5d67178564baf2b6d34 '@typescript-eslint/parser': 5.8.0_eslint@8.5.0+typescript@4.5.4 - c8: 7.10.0 - chai: 4.3.4 discord.js: 13.3.1 dotenv: 10.0.0 esbuild: 0.14.6 @@ -60,10 +56,8 @@ importers: eslint-plugin-react-hooks: 4.3.0_eslint@8.5.0 eslint-plugin-unicorn: 39.0.0_eslint@8.5.0 jest: 27.4.5 - nanoid: 3.1.30 prettier: 2.5.1 react: 17.0.2 - should: 13.2.3 tsup: 5.11.6_typescript@4.5.4 typescript: 4.5.4 @@ -1363,10 +1357,6 @@ packages: engines: {node: '>=8'} dev: true - /assertion-error/1.1.0: - resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} - dev: true - /assign-symbols/1.0.0: resolution: {integrity: sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=} engines: {node: '>=0.10.0'} @@ -1638,25 +1628,6 @@ packages: esbuild: 0.14.6 dev: true - /c8/7.10.0: - resolution: {integrity: sha512-OAwfC5+emvA6R7pkYFVBTOtI5ruf9DahffGmIqUc9l6wEh0h7iAFP6dt/V9Ioqlr2zW5avX9U9/w1I4alTRHkA==} - engines: {node: '>=10.12.0'} - hasBin: true - dependencies: - '@bcoe/v8-coverage': 0.2.3 - '@istanbuljs/schema': 0.1.3 - find-up: 5.0.0 - foreground-child: 2.0.0 - istanbul-lib-coverage: 3.2.0 - istanbul-lib-report: 3.0.0 - istanbul-reports: 3.1.1 - rimraf: 3.0.2 - test-exclude: 6.0.0 - v8-to-istanbul: 8.1.0 - yargs: 16.2.0 - yargs-parser: 20.2.9 - dev: true - /cac/6.7.12: resolution: {integrity: sha512-rM7E2ygtMkJqD9c7WnFU6fruFcN3xe4FM5yUmgxhZzIKJk4uHl9U/fhwdajGFQbQuv43FAUo1Fe8gX/oIKDeSA==} engines: {node: '>=8'} @@ -1713,18 +1684,6 @@ packages: rsvp: 4.8.5 dev: true - /chai/4.3.4: - resolution: {integrity: sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==} - engines: {node: '>=4'} - dependencies: - assertion-error: 1.1.0 - check-error: 1.0.2 - deep-eql: 3.0.1 - get-func-name: 2.0.0 - pathval: 1.1.1 - type-detect: 4.0.8 - dev: true - /chalk/2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -1756,10 +1715,6 @@ packages: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} dev: true - /check-error/1.0.2: - resolution: {integrity: sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=} - dev: true - /chokidar/3.5.2: resolution: {integrity: sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==} engines: {node: '>= 8.10.0'} @@ -2033,13 +1988,6 @@ packages: resolution: {integrity: sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=} dev: true - /deep-eql/3.0.1: - resolution: {integrity: sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==} - engines: {node: '>=0.12'} - dependencies: - type-detect: 4.0.8 - dev: true - /deep-is/0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true @@ -2935,14 +2883,6 @@ packages: path-exists: 4.0.0 dev: true - /find-up/5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - dev: true - /flat-cache/3.0.4: resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -2960,14 +2900,6 @@ packages: engines: {node: '>=0.10.0'} dev: true - /foreground-child/2.0.0: - resolution: {integrity: sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==} - engines: {node: '>=8.0.0'} - dependencies: - cross-spawn: 7.0.3 - signal-exit: 3.0.6 - dev: true - /form-data/3.0.1: resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==} engines: {node: '>= 6'} @@ -3013,10 +2945,6 @@ packages: engines: {node: 6.* || 8.* || >= 10.*} dev: true - /get-func-name/2.0.0: - resolution: {integrity: sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=} - dev: true - /get-intrinsic/1.1.1: resolution: {integrity: sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==} dependencies: @@ -4430,13 +4358,6 @@ packages: p-locate: 4.1.0 dev: true - /locate-path/6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - dependencies: - p-locate: 5.0.0 - dev: true - /lodash-es/4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} dev: true @@ -4617,7 +4538,6 @@ packages: resolution: {integrity: sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - dev: true /nanomatch/1.2.13: resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==} @@ -4918,13 +4838,6 @@ packages: p-try: 2.2.0 dev: true - /p-limit/3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - dependencies: - yocto-queue: 0.1.0 - dev: true - /p-locate/2.0.0: resolution: {integrity: sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=} engines: {node: '>=4'} @@ -4939,13 +4852,6 @@ packages: p-limit: 2.3.0 dev: true - /p-locate/5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - dependencies: - p-limit: 3.1.0 - dev: true - /p-map/2.1.0: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} @@ -5051,10 +4957,6 @@ packages: engines: {node: '>=8'} dev: true - /pathval/1.1.1: - resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} - dev: true - /picocolors/1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} dev: true @@ -5520,44 +5422,6 @@ packages: engines: {node: '>=8'} dev: true - /should-equal/2.0.0: - resolution: {integrity: sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==} - dependencies: - should-type: 1.4.0 - dev: true - - /should-format/3.0.3: - resolution: {integrity: sha1-m/yPdPo5IFxT04w01xcwPidxJPE=} - dependencies: - should-type: 1.4.0 - should-type-adaptors: 1.1.0 - dev: true - - /should-type-adaptors/1.1.0: - resolution: {integrity: sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==} - dependencies: - should-type: 1.4.0 - should-util: 1.0.1 - dev: true - - /should-type/1.4.0: - resolution: {integrity: sha1-B1bYzoRt/QmEOmlHcZ36DUz/XPM=} - dev: true - - /should-util/1.0.1: - resolution: {integrity: sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==} - dev: true - - /should/13.2.3: - resolution: {integrity: sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==} - dependencies: - should-equal: 2.0.0 - should-format: 3.0.3 - should-type: 1.4.0 - should-type-adaptors: 1.1.0 - should-util: 1.0.1 - dev: true - /side-channel/1.0.4: resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} dependencies: @@ -6340,8 +6204,3 @@ packages: y18n: 5.0.8 yargs-parser: 20.2.9 dev: true - - /yocto-queue/0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - dev: true diff --git a/src/button.tsx b/src/button.tsx new file mode 100644 index 0000000..6e942e2 --- /dev/null +++ b/src/button.tsx @@ -0,0 +1,67 @@ +import type { + BaseMessageComponentOptions, + EmojiResolvable, + MessageActionRowOptions, + MessageButtonStyle, + MessageOptions, +} from "discord.js" +import { nanoid } from "nanoid" +import React from "react" +import { ContainerInstance } from "./container-instance.js" +import { last } from "./helpers/last.js" +import { pick } from "./helpers/pick.js" +import { toUpper } from "./helpers/to-upper.js" + +export type ButtonStyle = Exclude, "link"> + +export type ButtonProps = { + style?: ButtonStyle + emoji?: EmojiResolvable + disabled?: boolean + children?: React.ReactNode +} + +export function Button(props: ButtonProps) { + return ( + new ButtonInstance(props)}> + {props.children} + + ) +} + +class ButtonInstance extends ContainerInstance { + readonly name = "Button" + + constructor(private readonly props: ButtonProps) { + super({ warnOnNonTextChildren: true }) + } + + override renderToMessage(options: MessageOptions) { + options.components ??= [] + + // i hate this + let actionRow: + | (Required & MessageActionRowOptions) + | undefined = last(options.components) + + if ( + !actionRow || + actionRow.components[0]?.type === "SELECT_MENU" || + actionRow.components.length >= 5 + ) { + actionRow = { + type: "ACTION_ROW", + components: [], + } + options.components.push(actionRow) + } + + actionRow.components.push({ + ...pick(this.props, "emoji", "disabled"), + type: "BUTTON", + style: this.props.style ? toUpper(this.props.style) : "SECONDARY", + label: this.getChildrenText(), + customId: nanoid(), + }) + } +} diff --git a/src/helpers/last.ts b/src/helpers/last.ts new file mode 100644 index 0000000..d58fb25 --- /dev/null +++ b/src/helpers/last.ts @@ -0,0 +1,3 @@ +export function last(array: T[]): T | undefined { + return array[array.length - 1] +} diff --git a/src/helpers/to-upper.ts b/src/helpers/to-upper.ts new file mode 100644 index 0000000..36195cf --- /dev/null +++ b/src/helpers/to-upper.ts @@ -0,0 +1,4 @@ +/** A typesafe version of toUpperCase */ +export function toUpper(string: S) { + return string.toUpperCase() as Uppercase +} diff --git a/src/main.ts b/src/main.ts index 548dbf1..7d56fd2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,4 @@ +export * from "./button" export * from "./embed" export * from "./embed-field" export * from "./root"