diff --git a/integration/rendering.test.tsx b/integration/rendering.test.tsx deleted file mode 100644 index 22b8ea1..0000000 --- a/integration/rendering.test.tsx +++ /dev/null @@ -1,413 +0,0 @@ -/* eslint-disable unicorn/no-null */ -import type { ButtonInteraction, Message, MessageOptions } from "discord.js" -import { Client, TextChannel } from "discord.js" -import { nanoid } from "nanoid" -import React, { useState } from "react" -import { afterAll, beforeAll, expect, test } from "vitest" -import { omit } from "../src/helpers/omit.js" -import { raise } from "../src/helpers/raise.js" -import { waitForWithTimeout } from "../src/helpers/wait-for-with-timeout.js" -import type { ReacordRoot } from "../src/main.js" -import { - ActionRow, - Button, - createRoot, - Embed, - EmbedField, - Text, -} from "../src/main.js" -import { testBotToken, testChannelId } from "./test-environment.js" - -const client = new Client({ - intents: ["GUILDS"], -}) - -let channel: TextChannel -let root: ReacordRoot - -beforeAll(async () => { - await client.login(testBotToken) - - const result = - client.channels.cache.get(testChannelId) ?? - (await client.channels.fetch(testChannelId)) ?? - raise("Channel not found") - - if (!(result instanceof TextChannel)) { - throw new TypeError("Channel must be a text channel") - } - - channel = result - - for (const [, message] of await channel.messages.fetch()) { - await message.delete() - } - - root = createRoot(channel) -}) - -afterAll(() => { - client.destroy() -}) - -test("rapid updates", async () => { - // rapid updates - void root.render("hi world") - void root.render("hi the") - await root.render("hi moon") - await assertMessages([{ content: "hi moon" }]) -}) - -test("nested text", async () => { - await root.render( - - hi world{" "} - - hi moon hi sun - - , - ) - await assertMessages([{ content: "hi world hi moon hi sun" }]) -}) - -test("empty embed fallback", async () => { - await root.render() - await assertMessages([{ embeds: [{ description: "_ _" }] }]) -}) - -test("embed with only author", async () => { - await root.render() - await assertMessages([{ embeds: [{ author: { name: "only author" } }] }]) -}) - -test("kitchen sink", async () => { - const timestamp = Date.now() - const image = - "https://cdn.discordapp.com/avatars/109677308410875904/3e53fcb70760a08fa63f73376ede5d1f.png?size=1024" - - await root.render( - <> - message content - no space - - description more description - - - another hi - field content - - field content but inline - - - - - - - - - - - - - , - ) - await assertMessages([ - { - content: "message contentno space", - embeds: [ - { - color: 0xfe_ee_ef, - description: "description more description", - image: { url: image }, - thumbnail: { url: image }, - author: { - name: "hi craw", - url: "https://example.com", - iconURL: - "https://cdn.discordapp.com/avatars/109677308410875904/3e53fcb70760a08fa63f73376ede5d1f.png?size=1024", - }, - footer: { - text: "the footer", - iconURL: - "https://cdn.discordapp.com/avatars/109677308410875904/3e53fcb70760a08fa63f73376ede5d1f.png?size=1024", - }, - timestamp, - title: "the embed", - url: "https://example.com", - }, - { - description: "another hi", - fields: [ - { name: "field name", value: "field content", inline: false }, - { - name: "field name", - value: "field content but inline", - inline: true, - }, - ], - }, - ], - 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, - }, - ], - }, - { - type: "ACTION_ROW", - components: [ - { - type: "BUTTON", - label: "new action row", - style: "SECONDARY", - disabled: false, - }, - ], - }, - ], - }, - ]) -}) - -test("button onClick", async () => { - let clicked = false - await root.render( - - ) - } - - async function assertCount(count: number) { - await assertMessages([ - { - content: `the count is ${count}`, - components: [ - { - type: "ACTION_ROW", - components: [ - { - type: "BUTTON", - style: "SECONDARY", - label: "increment", - disabled: false, - }, - ], - }, - ], - }, - ]) - } - - await root.render() - await assertCount(0) - await clickButton() - await assertCount(1) - await clickButton() - await assertCount(2) -}, 10_000) - -test("destroy", async () => { - await root.destroy() - await assertMessages([]) -}) - -async function assertMessages(expected: MessageOptions[]) { - const messages = await channel.messages.fetch() - - expect(messages.map((message) => extractMessageData(message))).toEqual( - expected, - ) - - return messages -} - -async function clickButton(index = 0) { - const messages = await channel.messages.fetch() - - const components = [...messages.values()] - .flatMap((message) => message.components.flatMap((row) => row.components)) - .flatMap((component) => (component.type === "BUTTON" ? [component] : [])) - - const customId = - components[index]?.customId ?? raise(`Button not found at index ${index}`) - - global.setTimeout(() => { - channel.client.emit("interactionCreate", createButtonInteraction(customId)) - }) - await channel.awaitMessageComponent({ - filter: (interaction) => interaction.customId === customId, - time: 1000, - }) - await root.done() -} - -function createButtonInteraction(customId: string) { - return { - id: nanoid(), - type: "MESSAGE_COMPONENT", - componentType: "BUTTON", - channelId: channel.id, - guildId: channel.guildId, - isButton: () => true, - customId, - user: { id: "123" }, - deferUpdate: () => Promise.resolve(), - } as ButtonInteraction -} - -function extractMessageData(message: Message): MessageOptions { - return pruneUndefinedValues({ - content: nonEmptyOrUndefined(message.content), - embeds: nonEmptyOrUndefined( - pruneUndefinedValues( - message.embeds.map((embed) => ({ - title: embed.title ?? undefined, - description: embed.description ?? undefined, - url: embed.url ?? undefined, - timestamp: embed.timestamp ?? undefined, - color: embed.color ?? undefined, - fields: nonEmptyOrUndefined(embed.fields), - author: embed.author ? omit(embed.author, "proxyIconURL") : undefined, - thumbnail: embed.thumbnail - ? omit(embed.thumbnail, "proxyURL", "width", "height") - : undefined, - image: embed.image - ? omit(embed.image, "proxyURL", "width", "height") - : undefined, - video: embed.video ?? undefined, - footer: embed.footer ? omit(embed.footer, "proxyIconURL") : undefined, - })), - ), - ), - 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 pruneUndefinedValues(input: T) { - return JSON.parse(JSON.stringify(input)) -} - -function nonEmptyOrUndefined(input: T): T | undefined { - if ( - input == undefined || - input === "" || - (Array.isArray(input) && input.length === 0) - ) { - return undefined - } - return input -} diff --git a/integration/test-environment.ts b/integration/test-environment.ts deleted file mode 100644 index ac49a85..0000000 --- a/integration/test-environment.ts +++ /dev/null @@ -1,10 +0,0 @@ -import "dotenv/config.js" -import { raise } from "../src/helpers/raise.js" - -function getEnvironmentValue(name: string) { - return process.env[name] ?? raise(`Missing environment variable: ${name}`) -} - -export const testBotToken = getEnvironmentValue("TEST_BOT_TOKEN") -// export const testGuildId = getEnvironmentValue("TEST_GUILD_ID") -export const testChannelId = getEnvironmentValue("TEST_CHANNEL_ID") diff --git a/playground/counter.tsx b/playground/counter.tsx index 07456f6..6fe6c8b 100644 --- a/playground/counter.tsx +++ b/playground/counter.tsx @@ -1,5 +1,5 @@ import * as React from "react" -import { Button, Embed, EmbedField, EmbedTitle } from "../src.new/main" +import { Button, Embed, EmbedField, EmbedTitle } from "../src/main" export function Counter(props: { onDeactivate: () => void }) { const [count, setCount] = React.useState(0) diff --git a/playground/main.tsx b/playground/main.tsx index 9faef31..86c062d 100644 --- a/playground/main.tsx +++ b/playground/main.tsx @@ -1,7 +1,7 @@ import { Client } from "discord.js" import "dotenv/config" import React from "react" -import { Reacord } from "../src.new/main.js" +import { Reacord } from "../src/main.js" import { createCommandHandler } from "./command-handler.js" import { Counter } from "./counter.js" diff --git a/src.new/main.ts b/src.new/main.ts deleted file mode 100644 index 24cbec5..0000000 --- a/src.new/main.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./button" -export * from "./embed/embed" -export * from "./embed/embed-field" -export * from "./embed/embed-title" -export * from "./reacord" diff --git a/src.new/node.ts b/src.new/node.ts deleted file mode 100644 index 0b30cba..0000000 --- a/src.new/node.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* eslint-disable class-methods-use-this */ -import type { MessageComponentInteraction, MessageOptions } from "discord.js" -import { Container } from "./container.js" - -export abstract class Node { - readonly children = new Container>() - protected props: Props - - constructor(initialProps: Props) { - this.props = initialProps - } - - setProps(props: Props) { - this.props = props - } - - modifyMessageOptions(options: MessageOptions) {} - - handleInteraction( - interaction: MessageComponentInteraction, - ): true | undefined { - return undefined - } -} diff --git a/src.new/reconciler.ts b/src.new/reconciler.ts deleted file mode 100644 index cc66afa..0000000 --- a/src.new/reconciler.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { HostConfig } from "react-reconciler" -import ReactReconciler from "react-reconciler" -import { raise } from "../src/helpers/raise.js" -import { Node } from "./node.js" -import type { Renderer } from "./renderer.js" -import { TextNode } from "./text.js" - -const config: HostConfig< - string, // Type, - Record, // Props, - Renderer, // Container, - Node, // Instance, - TextNode, // TextInstance, - never, // SuspenseInstance, - never, // HydratableInstance, - never, // PublicInstance, - never, // HostContext, - true, // UpdatePayload, - never, // ChildSet, - number, // TimeoutHandle, - number // NoTimeout, -> = { - // config - now: Date.now, - supportsMutation: true, - supportsPersistence: false, - supportsHydration: false, - isPrimaryRenderer: true, - scheduleTimeout: global.setTimeout, - cancelTimeout: global.clearTimeout, - noTimeout: -1, - - // eslint-disable-next-line unicorn/no-null - getRootHostContext: () => null, - getChildHostContext: (parentContext) => parentContext, - - createInstance: (type, props) => { - if (type !== "reacord-element") { - raise(`Unknown element type: ${type}`) - } - - if (typeof props.createNode !== "function") { - raise(`Missing createNode function`) - } - - const node = props.createNode(props.props) - if (!(node instanceof Node)) { - raise(`createNode function did not return a Node`) - } - - return node - }, - createTextInstance: (text) => new TextNode(text), - shouldSetTextContent: () => false, - - clearContainer: (renderer) => { - renderer.nodes.clear() - }, - appendChildToContainer: (renderer, child) => { - renderer.nodes.add(child) - }, - removeChildFromContainer: (renderer, child) => { - renderer.nodes.remove(child) - }, - insertInContainerBefore: (renderer, child, before) => { - renderer.nodes.addBefore(child, before) - }, - - appendInitialChild: (parent, child) => { - parent.children.add(child) - }, - appendChild: (parent, child) => { - parent.children.add(child) - }, - removeChild: (parent, child) => { - parent.children.remove(child) - }, - insertBefore: (parent, child, before) => { - parent.children.addBefore(child, before) - }, - - prepareUpdate: () => true, - commitUpdate: (node, payload, type, oldProps, newProps) => { - node.setProps(newProps.props) - }, - commitTextUpdate: (node, oldText, newText) => { - node.setProps(newText) - }, - - // eslint-disable-next-line unicorn/no-null - prepareForCommit: () => null, - resetAfterCommit: (renderer) => { - renderer.render() - }, - - preparePortalMount: () => raise("Portals are not supported"), - getPublicInstance: () => raise("Refs are currently not supported"), - - finalizeInitialChildren: () => false, -} - -export const reconciler = ReactReconciler(config) diff --git a/src/action-queue.test.ts b/src/action-queue.test.ts deleted file mode 100644 index 330f315..0000000 --- a/src/action-queue.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { setTimeout } from "node:timers/promises" -import { expect, test } from "vitest" -import { ActionQueue } from "./action-queue.js" - -test("action queue", async () => { - const queue = new ActionQueue() - let results: string[] = [] - - queue.add({ - id: "a", - priority: 1, - run: async () => { - await setTimeout(100) - results.push("a") - }, - }) - - queue.add({ - id: "b", - priority: 0, - run: async () => { - await setTimeout(50) - results.push("b") - }, - }) - - expect(results).toEqual([]) - await queue.done() - expect(results).toEqual(["b", "a"]) -}) diff --git a/src/action-queue.ts b/src/action-queue.ts deleted file mode 100644 index 4fdfca1..0000000 --- a/src/action-queue.ts +++ /dev/null @@ -1,45 +0,0 @@ -export type Action = { - id: string - priority: number - run: () => unknown -} - -export class ActionQueue { - private actions: Action[] = [] - private runningPromise?: Promise - - add(action: Action) { - this.actions.push(action) - this.actions.sort((a, b) => a.priority - b.priority) - this.runActions() - } - - clear() { - this.actions = [] - } - - done() { - return this.runningPromise ?? Promise.resolve() - } - - private runActions() { - if (this.runningPromise) return - - this.runningPromise = new Promise((resolve) => { - // using a microtask to allow multiple actions to be added synchronously - queueMicrotask(async () => { - let action: Action | undefined - while ((action = this.actions.shift())) { - try { - await action.run() - } catch (error) { - console.error(`Failed to run action:`, action) - console.error(error) - } - } - resolve() - this.runningPromise = undefined - }) - }) - } -} diff --git a/src.new/button.tsx b/src/button.tsx similarity index 88% rename from src.new/button.tsx rename to src/button.tsx index f421650..a2dc16d 100644 --- a/src.new/button.tsx +++ b/src/button.tsx @@ -9,8 +9,9 @@ import type { import { MessageActionRow } from "discord.js" import { nanoid } from "nanoid" import React from "react" -import { last } from "../src/helpers/last.js" -import { toUpper } from "../src/helpers/to-upper.js" +import { ReacordElement } from "./element.js" +import { last } from "./helpers/last.js" +import { toUpper } from "./helpers/to-upper.js" import { Node } from "./node.js" export type ButtonProps = { @@ -23,7 +24,7 @@ export type ButtonProps = { export function Button(props: ButtonProps) { return ( - new ButtonNode(props)} /> + new ButtonNode(props)} /> ) } diff --git a/src/channel-renderer.ts b/src/channel-renderer.ts deleted file mode 100644 index 489b0c4..0000000 --- a/src/channel-renderer.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { - InteractionCollector, - Message, - MessageComponentInteraction, - MessageComponentType, - TextBasedChannels, -} from "discord.js" -import type { Action } from "./action-queue.js" -import { ActionQueue } from "./action-queue.js" -import { collectInteractionHandlers } from "./collect-interaction-handlers" -import { createMessageOptions } from "./create-message-options" -import type { MessageNode } from "./node.js" - -export class ChannelRenderer { - private channel: TextBasedChannels - private interactionCollector: InteractionCollector - private message?: Message - private tree?: MessageNode - private actions = new ActionQueue() - - constructor(channel: TextBasedChannels) { - this.channel = channel - this.interactionCollector = this.createInteractionCollector() - } - - private getInteractionHandler(customId: string) { - if (!this.tree) return undefined - const handlers = collectInteractionHandlers(this.tree) - return handlers.find((handler) => handler.customId === customId) - } - - private createInteractionCollector() { - const collector = - this.channel.createMessageComponentCollector({ - filter: (interaction) => - !!this.getInteractionHandler(interaction.customId), - }) - - collector.on("collect", (interaction) => { - const handler = this.getInteractionHandler(interaction.customId) - if (handler?.type === "button" && interaction.isButton()) { - interaction.deferUpdate().catch(console.error) - handler.onClick(interaction) - } - }) - - return collector as InteractionCollector - } - - render(node: MessageNode) { - this.actions.add(this.createUpdateMessageAction(node)) - } - - destroy() { - this.actions.clear() - this.actions.add(this.createDeleteMessageAction()) - this.interactionCollector.stop() - } - - done() { - return this.actions.done() - } - - private createUpdateMessageAction(node: MessageNode): Action { - return { - id: "updateMessage", - priority: 0, - run: async () => { - const options = createMessageOptions(node) - - // eslint-disable-next-line unicorn/prefer-ternary - if (this.message) { - this.message = await this.message.edit({ - ...options, - - // need to ensure that the proper fields are erased if there's no content - // eslint-disable-next-line unicorn/no-null - content: options.content ?? null, - // eslint-disable-next-line unicorn/no-null - embeds: options.embeds ?? [], - }) - } else { - this.message = await this.channel.send(options) - } - - this.tree = node - }, - } - } - - private createDeleteMessageAction(): Action { - return { - id: "deleteMessage", - priority: 0, - run: () => this.message?.delete(), - } - } -} diff --git a/src/collect-interaction-handlers.ts b/src/collect-interaction-handlers.ts deleted file mode 100644 index 9b730cd..0000000 --- a/src/collect-interaction-handlers.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ButtonInteraction } from "discord.js" -import type { Node } from "./node" - -type InteractionHandler = { - type: "button" - customId: string - onClick: (interaction: ButtonInteraction) => void -} - -export function collectInteractionHandlers(node: Node): InteractionHandler[] { - if (node.type === "button") { - return [{ type: "button", customId: node.customId, onClick: node.onClick }] - } - - if ("children" in node) { - return node.children.flatMap(collectInteractionHandlers) - } - - return [] -} diff --git a/src/components/action-row.tsx b/src/components/action-row.tsx deleted file mode 100644 index c68c5ff..0000000 --- a/src/components/action-row.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from "react" - -export type ActionRowProps = { - children: React.ReactNode -} - -export function ActionRow(props: ActionRowProps) { - return ( - ({ type: "actionRow", children: [] })}> - {props.children} - - ) -} diff --git a/src/components/button.tsx b/src/components/button.tsx deleted file mode 100644 index e71e12b..0000000 --- a/src/components/button.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { - ButtonInteraction, - EmojiResolvable, - MessageButtonStyle, -} from "discord.js" -import { nanoid } from "nanoid" -import React from "react" - -export type ButtonStyle = Exclude, "link"> - -export type ButtonProps = { - style?: ButtonStyle - emoji?: EmojiResolvable - disabled?: boolean - onClick: (interaction: ButtonInteraction) => void - children?: React.ReactNode -} - -export function Button(props: ButtonProps) { - return ( - ({ - ...props, - type: "button", - children: [], - customId: nanoid(), - })} - > - {props.children} - - ) -} diff --git a/src/components/embed-field.tsx b/src/components/embed-field.tsx deleted file mode 100644 index 881bf8b..0000000 --- a/src/components/embed-field.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from "react" - -export type EmbedFieldProps = { - name: string - children: React.ReactNode - inline?: boolean -} - -export function EmbedField(props: EmbedFieldProps) { - return ( - ({ ...props, type: "embedField", children: [] })} - > - {props.children} - - ) -} diff --git a/src/components/embed.tsx b/src/components/embed.tsx deleted file mode 100644 index 51afa6e..0000000 --- a/src/components/embed.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { ColorResolvable } from "discord.js" -import type { ReactNode } from "react" -import React from "react" - -export type EmbedProps = { - title?: string - color?: ColorResolvable - url?: string - timestamp?: Date | number | string - imageUrl?: string - thumbnailUrl?: string - author?: { - name: string - url?: string - iconUrl?: string - } - footer?: { - text: string - iconUrl?: string - } - children?: ReactNode -} - -export function Embed(props: EmbedProps) { - return ( - ({ ...props, type: "embed", children: [] })} - > - {props.children} - - ) -} diff --git a/src/components/text.tsx b/src/components/text.tsx deleted file mode 100644 index c46a50c..0000000 --- a/src/components/text.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { ReactNode } from "react" -import React from "react" - -export type TextProps = { - children?: ReactNode -} - -export function Text(props: TextProps) { - return ( - ({ type: "textElement", children: [] })}> - {props.children} - - ) -} diff --git a/src.new/container.ts b/src/container.ts similarity index 100% rename from src.new/container.ts rename to src/container.ts diff --git a/src/create-message-options.ts b/src/create-message-options.ts deleted file mode 100644 index e70685a..0000000 --- a/src/create-message-options.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { - BaseMessageComponentOptions, - MessageActionRowOptions, - MessageEmbedOptions, - MessageOptions, -} from "discord.js" -import { last } from "./helpers/last.js" -import { toUpper } from "./helpers/to-upper.js" -import type { EmbedNode, MessageNode, Node } from "./node" - -export function createMessageOptions(node: MessageNode): MessageOptions { - if (node.children.length === 0) { - // can't send an empty message - return { content: "_ _" } - } - - const options: MessageOptions = {} - - for (const child of node.children) { - if (child.type === "text" || child.type === "textElement") { - options.content = `${options.content ?? ""}${getNodeText(child)}` - } - - if (child.type === "embed") { - options.embeds ??= [] - options.embeds.push(getEmbedOptions(child)) - } - - if (child.type === "actionRow") { - options.components ??= [] - options.components.push({ - type: "ACTION_ROW", - components: [], - }) - addActionRowItems(options.components, child.children) - } - - if (child.type === "button") { - options.components ??= [] - addActionRowItems(options.components, [child]) - } - } - - if (!options.content && !options.embeds?.length) { - options.content = "_ _" - } - - return options -} - -function getNodeText(node: Node): string | undefined { - if (node.type === "text") { - return node.text - } - if (node.type === "textElement") { - return node.children.map(getNodeText).join("") - } -} - -function getEmbedOptions(node: EmbedNode) { - const options: MessageEmbedOptions = { - title: node.title, - color: node.color, - url: node.url, - timestamp: node.timestamp ? new Date(node.timestamp) : undefined, - image: { url: node.imageUrl }, - thumbnail: { url: node.thumbnailUrl }, - description: node.children.map(getNodeText).join(""), - author: node.author - ? { ...node.author, iconURL: node.author.iconUrl } - : undefined, - footer: node.footer - ? { text: node.footer.text, iconURL: node.footer.iconUrl } - : undefined, - } - - for (const child of node.children) { - if (child.type === "embedField") { - options.fields ??= [] - options.fields.push({ - name: child.name, - value: child.children.map(getNodeText).join("") || "_ _", - inline: child.inline, - }) - } - } - - if (!options.description && !options.author) { - options.description = "_ _" - } - - return options -} - -type ActionRowOptions = Required & - MessageActionRowOptions - -function addActionRowItems(components: ActionRowOptions[], nodes: Node[]) { - let actionRow = last(components) - - if ( - actionRow == undefined || - actionRow.components[0]?.type === "SELECT_MENU" || - actionRow.components.length >= 5 - ) { - actionRow = { - type: "ACTION_ROW", - components: [], - } - components.push(actionRow) - } - - for (const node of nodes) { - if (node.type === "button") { - actionRow.components.push({ - type: "BUTTON", - label: node.children.map(getNodeText).join(""), - style: node.style ? toUpper(node.style) : "SECONDARY", - emoji: node.emoji, - disabled: node.disabled, - customId: node.customId, - }) - } - } -} diff --git a/src.new/element.ts b/src/element.ts similarity index 100% rename from src.new/element.ts rename to src/element.ts diff --git a/src.new/embed/embed-child.ts b/src/embed/embed-child.ts similarity index 100% rename from src.new/embed/embed-child.ts rename to src/embed/embed-child.ts diff --git a/src.new/embed/embed-field.tsx b/src/embed/embed-field.tsx similarity index 100% rename from src.new/embed/embed-field.tsx rename to src/embed/embed-field.tsx diff --git a/src.new/embed/embed-title.tsx b/src/embed/embed-title.tsx similarity index 100% rename from src.new/embed/embed-title.tsx rename to src/embed/embed-title.tsx diff --git a/src.new/embed/embed.tsx b/src/embed/embed.tsx similarity index 100% rename from src.new/embed/embed.tsx rename to src/embed/embed.tsx diff --git a/src/helpers/get-environment-value.ts b/src/helpers/get-environment-value.ts new file mode 100644 index 0000000..4f74305 --- /dev/null +++ b/src/helpers/get-environment-value.ts @@ -0,0 +1,5 @@ +import { raise } from "./raise.js" + +export function getEnvironmentValue(name: string) { + return process.env[name] ?? raise(`Missing environment variable: ${name}`) +} diff --git a/src/helpers/omit.ts b/src/helpers/omit.ts index d30cd42..8dd20d8 100644 --- a/src/helpers/omit.ts +++ b/src/helpers/omit.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/no-unused-modules export function omit( subject: Subject, ...keys: Key[] diff --git a/src/jsx.d.ts b/src/jsx.d.ts deleted file mode 100644 index 3ee2cc5..0000000 --- a/src/jsx.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -declare global { - // namespace JSX { - // // eslint-disable-next-line @typescript-eslint/consistent-type-definitions - // interface IntrinsicElements { - // "reacord-element": { - // createNode: () => Node - // children?: ReactNode - // } - // } - // } -} diff --git a/src/main.ts b/src/main.ts index 4968980..24cbec5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,5 @@ -/* eslint-disable import/no-unused-modules */ -export * from "./components/action-row.jsx" -export * from "./components/button.jsx" -export * from "./components/embed-field.jsx" -export * from "./components/embed.jsx" -export * from "./components/text.jsx" -export * from "./root.js" +export * from "./button" +export * from "./embed/embed" +export * from "./embed/embed-field" +export * from "./embed/embed-title" +export * from "./reacord" diff --git a/src/node.ts b/src/node.ts index 5326f40..0b30cba 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,72 +1,24 @@ -import type { - ButtonInteraction, - ColorResolvable, - EmojiResolvable, -} from "discord.js" -import type { ButtonStyle } from "./components/button.jsx" +/* eslint-disable class-methods-use-this */ +import type { MessageComponentInteraction, MessageOptions } from "discord.js" +import { Container } from "./container.js" -export type MessageNode = { - type: "message" - children: Node[] -} +export abstract class Node { + readonly children = new Container>() + protected props: Props -export type TextNode = { - type: "text" - text: string -} - -type TextElementNode = { - type: "textElement" - children: Node[] -} - -export type EmbedNode = { - type: "embed" - title?: string - color?: ColorResolvable - url?: string - timestamp?: Date | number | string - imageUrl?: string - thumbnailUrl?: string - author?: { - name: string - url?: string - iconUrl?: string + constructor(initialProps: Props) { + this.props = initialProps } - footer?: { - text: string - iconUrl?: string + + setProps(props: Props) { + this.props = props } - children: Node[] -} -type EmbedFieldNode = { - type: "embedField" - name: string - inline?: boolean - children: Node[] -} + modifyMessageOptions(options: MessageOptions) {} -type ActionRowNode = { - type: "actionRow" - children: Node[] + handleInteraction( + interaction: MessageComponentInteraction, + ): true | undefined { + return undefined + } } - -type ButtonNode = { - type: "button" - style?: ButtonStyle - emoji?: EmojiResolvable - disabled?: boolean - customId: string - onClick: (interaction: ButtonInteraction) => void - children: Node[] -} - -export type Node = - | MessageNode - | TextNode - | TextElementNode - | EmbedNode - | EmbedFieldNode - | ActionRowNode - | ButtonNode diff --git a/src/reacord.test.tsx b/src/reacord.test.tsx new file mode 100644 index 0000000..566255f --- /dev/null +++ b/src/reacord.test.tsx @@ -0,0 +1,5 @@ +import "dotenv/config.js" +import { getEnvironmentValue } from "./helpers/get-environment-value.js" + +const testBotToken = getEnvironmentValue("TEST_BOT_TOKEN") +const testChannelId = getEnvironmentValue("TEST_CHANNEL_ID") diff --git a/src.new/reacord.ts b/src/reacord.ts similarity index 100% rename from src.new/reacord.ts rename to src/reacord.ts diff --git a/src/reconciler.ts b/src/reconciler.ts index b5755c8..3cb8f65 100644 --- a/src/reconciler.ts +++ b/src/reconciler.ts @@ -1,114 +1,102 @@ -/* eslint-disable unicorn/no-null */ -import { inspect } from "node:util" +import type { HostConfig } from "react-reconciler" import ReactReconciler from "react-reconciler" -import type { ChannelRenderer } from "./channel-renderer.js" import { raise } from "./helpers/raise.js" -import type { MessageNode, Node, TextNode } from "./node.js" +import { Node } from "./node.js" +import type { Renderer } from "./renderer.js" +import { TextNode } from "./text.js" -type ElementTag = string - -type Props = Record - -const createInstance = (type: string, props: Props): Node => { - if (type !== "reacord-element") { - raise(`createInstance: unknown type: ${type}`) - } - - if (typeof props.createNode !== "function") { - const actual = inspect(props.createNode) - raise(`invalid createNode function, received: ${actual}`) - } - - return props.createNode() -} - -type ChildSet = MessageNode - -export const reconciler = ReactReconciler< - string, // Type (jsx tag), - Props, // Props, - ChannelRenderer, // Container, - Node, // Instance, +const config: HostConfig< + string, // Type, + Record, // Props, + Renderer, // Container, + Node, // Instance, TextNode, // TextInstance, never, // SuspenseInstance, never, // HydratableInstance, never, // PublicInstance, - null, // HostContext, - [], // UpdatePayload, - ChildSet, // ChildSet, - unknown, // TimeoutHandle, - unknown // NoTimeout ->({ + never, // HostContext, + true, // UpdatePayload, + never, // ChildSet, + number, // TimeoutHandle, + number // NoTimeout, +> = { + // config now: Date.now, - isPrimaryRenderer: true, - supportsMutation: false, - supportsPersistence: true, + supportsMutation: true, + supportsPersistence: false, supportsHydration: false, - scheduleTimeout: setTimeout, - cancelTimeout: clearTimeout, + isPrimaryRenderer: true, + scheduleTimeout: global.setTimeout, + cancelTimeout: global.clearTimeout, noTimeout: -1, + // eslint-disable-next-line unicorn/no-null getRootHostContext: () => null, getChildHostContext: (parentContext) => parentContext, + + createInstance: (type, props) => { + if (type !== "reacord-element") { + raise(`Unknown element type: ${type}`) + } + + if (typeof props.createNode !== "function") { + raise(`Missing createNode function`) + } + + const node = props.createNode(props.props) + if (!(node instanceof Node)) { + raise(`createNode function did not return a Node`) + } + + return node + }, + createTextInstance: (text) => new TextNode(text), shouldSetTextContent: () => false, - createInstance, - - createTextInstance: (text) => ({ type: "text", text }), - - createContainerChildSet: (): ChildSet => ({ - type: "message", - children: [], - }), - - appendChildToContainerChildSet: (childSet: ChildSet, child: Node) => { - childSet.children.push(child) + clearContainer: (renderer) => { + renderer.nodes.clear() }, - - finalizeContainerChildren: (container: ChannelRenderer, children: ChildSet) => - false, - - replaceContainerChildren: ( - container: ChannelRenderer, - children: ChildSet, - ) => { - container.render(children) + appendChildToContainer: (renderer, child) => { + renderer.nodes.add(child) + }, + removeChildFromContainer: (renderer, child) => { + renderer.nodes.remove(child) + }, + insertInContainerBefore: (renderer, child, before) => { + renderer.nodes.addBefore(child, before) }, appendInitialChild: (parent, child) => { - if ("children" in parent) { - parent.children.push(child) - } else { - raise(`${parent.type} cannot have children`) - } + parent.children.add(child) + }, + appendChild: (parent, child) => { + parent.children.add(child) + }, + removeChild: (parent, child) => { + parent.children.remove(child) + }, + insertBefore: (parent, child, before) => { + parent.children.addBefore(child, before) }, - cloneInstance: ( - instance: Node, - _: unknown, - type: ElementTag, - oldProps: Props, - newProps: Props, - ) => { - const newInstance = createInstance(type, newProps) - - // instance children don't get carried over, so we need to copy them - if ("children" in instance && "children" in newInstance) { - newInstance.children = instance.children - } - - return newInstance + prepareUpdate: () => true, + commitUpdate: (node, payload, type, oldProps, newProps) => { + node.setProps(newProps.props) + }, + commitTextUpdate: (node, oldText, newText) => { + node.setProps(newText) }, - // returning a non-null value tells react to re-render the whole thing - // on any prop change - // - // we can probably optimize this to actually compare old/new props though - prepareUpdate: () => [], + // eslint-disable-next-line unicorn/no-null + prepareForCommit: () => null, + resetAfterCommit: (renderer) => { + renderer.render() + }, + + preparePortalMount: () => raise("Portals are not supported"), + getPublicInstance: () => raise("Refs are currently not supported"), finalizeInitialChildren: () => false, - prepareForCommit: (container) => null, - resetAfterCommit: () => null, - getPublicInstance: () => raise("Not implemented"), - preparePortalMount: () => raise("Not implemented"), -}) +} + +export const reconciler = ReactReconciler(config) diff --git a/src.new/renderer.ts b/src/renderer.ts similarity index 100% rename from src.new/renderer.ts rename to src/renderer.ts diff --git a/src/root.ts b/src/root.ts deleted file mode 100644 index a38b973..0000000 --- a/src/root.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* eslint-disable unicorn/no-null */ -import type { TextBasedChannels } from "discord.js" -import type { ReactNode } from "react" -import { ChannelRenderer } from "./channel-renderer.js" -import { reconciler } from "./reconciler.js" - -export type ReacordRoot = ReturnType - -export function createRoot(target: TextBasedChannels) { - const renderer = new ChannelRenderer(target) - const containerId = reconciler.createContainer(renderer, 0, false, null) - return { - render: (content: ReactNode) => { - reconciler.updateContainer(content, containerId) - return renderer.done() - }, - destroy: () => { - reconciler.updateContainer(null, containerId) - renderer.destroy() - return renderer.done() - }, - done: () => renderer.done(), - } -} diff --git a/src.new/text.ts b/src/text.ts similarity index 100% rename from src.new/text.ts rename to src/text.ts