diff --git a/packages/reacord/helpers/container.ts b/packages/reacord/helpers/container.ts deleted file mode 100644 index 75ce2c9..0000000 --- a/packages/reacord/helpers/container.ts +++ /dev/null @@ -1,27 +0,0 @@ -export class Container { - private items: T[] = [] - - getItems(): readonly T[] { - return this.items - } - - add(item: T) { - this.items.push(item) - } - - remove(item: T) { - const index = this.items.indexOf(item) - if (index === -1) return - this.items.splice(index, 1) - } - - clear() { - this.items = [] - } - - insertBefore(item: T, beforeItem: T) { - const index = this.items.indexOf(beforeItem) - if (index === -1) return - this.items.splice(index, 0, item) - } -} diff --git a/packages/reacord/library.new/core/button.tsx b/packages/reacord/library.new/core/button.tsx index 445735a..27b5434 100644 --- a/packages/reacord/library.new/core/button.tsx +++ b/packages/reacord/library.new/core/button.tsx @@ -1,10 +1,9 @@ import { randomUUID } from "node:crypto" -import React, { useState } from "react" +import React from "react" import type { Except } from "type-fest" import type { ButtonSharedProps } from "./button-shared-props" import type { ComponentEvent } from "./component-event" -import type { NodeBase } from "./node" -import { makeNode } from "./node" +import { Node } from "./node" import { ReacordElement } from "./reacord-element" /** @@ -28,16 +27,18 @@ export type ButtonProps = ButtonSharedProps & { */ export type ButtonClickEvent = ComponentEvent -export type ButtonNode = NodeBase< - "button", - Except & { customId: string } -> - export function Button({ label, ...props }: ButtonProps) { - const [customId] = useState(() => randomUUID()) return ( - + new ButtonNode(props)} + nodeProps={props} + > {label} ) } + +export class ButtonNode extends Node> { + readonly customId = randomUUID() +} diff --git a/packages/reacord/library.new/core/make-message-payload.ts b/packages/reacord/library.new/core/make-message-payload.ts index 5b7a7e8..ec57ea5 100644 --- a/packages/reacord/library.new/core/make-message-payload.ts +++ b/packages/reacord/library.new/core/make-message-payload.ts @@ -5,21 +5,21 @@ import type { } from "discord-api-types/v10" import { ButtonStyle, ComponentType } from "discord-api-types/v10" import type { ButtonProps } from "./button" +import { ButtonNode } from "./button" import type { Node } from "./node" +import { TextNode } from "./text-node" -export type MessagePayload = RESTPostAPIChannelMessageJSONBody +export type MessageUpdatePayload = RESTPostAPIChannelMessageJSONBody -export function makeMessagePayload(tree: readonly Node[]) { - const payload: MessagePayload = {} +export function makeMessageUpdatePayload(root: Node) { + const payload: MessageUpdatePayload = {} - const content = tree - .map((item) => (item.type === "text" ? item.props.text : "")) - .join("") + const content = extractText(root, 1) if (content) { payload.content = content } - const actionRows = makeActionRows(tree) + const actionRows = makeActionRows(root) if (actionRows.length > 0) { payload.components = actionRows } @@ -27,11 +27,11 @@ export function makeMessagePayload(tree: readonly Node[]) { return payload } -function makeActionRows(tree: readonly Node[]) { +function makeActionRows(root: Node) { const actionRows: Array> = [] - for (const node of tree) { - if (node.type === "button") { + for (const node of root.children) { + if (node instanceof ButtonNode) { let currentRow = actionRows[actionRows.length - 1] if (!currentRow || currentRow.components.length === 5) { currentRow = { @@ -43,8 +43,8 @@ function makeActionRows(tree: readonly Node[]) { currentRow.components.push({ type: ComponentType.Button, - custom_id: node.props.customId, - label: extractText(node.children.getItems()), + custom_id: node.customId, + label: extractText(node, Number.POSITIVE_INFINITY), emoji: { name: node.props.emoji }, style: translateButtonStyle(node.props.style ?? "secondary"), disabled: node.props.disabled, @@ -55,14 +55,10 @@ function makeActionRows(tree: readonly Node[]) { return actionRows } -function extractText(tree: readonly Node[]): string { - return tree - .map((item) => { - return item.type === "text" - ? item.props.text - : extractText(item.children.getItems()) - }) - .join("") +function extractText(node: Node, depth: number): string { + if (node instanceof TextNode) return node.props.text + if (depth <= 0) return "" + return node.children.map((child) => extractText(child, depth - 1)).join("") } function translateButtonStyle(style: NonNullable) { diff --git a/packages/reacord/library.new/core/node.ts b/packages/reacord/library.new/core/node.ts index 5d73e45..985abc1 100644 --- a/packages/reacord/library.new/core/node.ts +++ b/packages/reacord/library.new/core/node.ts @@ -1,37 +1,33 @@ -import type { ButtonNode } from "./button" -import { Container } from "../../helpers/container" +export class Node { + private readonly _children: Node[] = [] -export type NodeBase = { - type: Type - props: Props - children: Container -} + constructor(public props: Props) {} -export type Node = TextNode | ButtonNode | ActionRowNode + get children(): readonly Node[] { + return this._children + } -export type TextNode = NodeBase<"text", { text: string }> + clear() { + this._children.splice(0) + } -export type ActionRowNode = NodeBase<"actionRow", {}> + add(...nodes: Node[]) { + this._children.push(...nodes) + } -export const makeNode = ( - type: Type, - props: Extract["props"], -) => - ({ type, props, children: new Container() } as Extract) + remove(node: Node) { + const index = this._children.indexOf(node) + if (index !== -1) this._children.splice(index, 1) + } -/** A wrapper for ensuring we're actually working with a real node - * inside the React reconciler - */ -export class NodeRef { - constructor(public readonly node: Node) {} + insertBefore(node: Node, beforeNode: Node) { + const index = this._children.indexOf(beforeNode) + if (index !== -1) this._children.splice(index, 0, node) + } - static unwrap(maybeNodeRef: unknown): Node { - if (maybeNodeRef instanceof NodeRef) { - return maybeNodeRef.node - } - const received = maybeNodeRef as Object | null | undefined - throw new TypeError( - `Expected ${NodeRef.name}, received instance of "${received?.constructor.name}"`, - ) + clone(): this { + const cloned: this = new (this.constructor as any)(this.props) + cloned.add(...this.children.map((child) => child.clone())) + return cloned } } diff --git a/packages/reacord/library.new/core/reacord-element.ts b/packages/reacord/library.new/core/reacord-element.ts index 00b1473..dd1ac2a 100644 --- a/packages/reacord/library.new/core/reacord-element.ts +++ b/packages/reacord/library.new/core/reacord-element.ts @@ -1,18 +1,43 @@ import type { ReactNode } from "react" -import React from "react" +import { createElement } from "react" +import { inspect } from "node:util" import type { Node } from "./node" -import { NodeRef } from "./node" -export function ReacordElement({ - node, +export function ReacordElement({ + name, + createNode, + nodeProps, children, }: { - node: Node + // A name representing what type of element this is, + // so that react will know if/when it needs to recreate the node, + // or just assign the props if the element name is the same on re-render + name: string + createNode: () => Node + nodeProps: NodeProps children?: ReactNode }) { - return React.createElement( - "reacord-element", - { node: new NodeRef(node) }, + return createElement( + `reacord-${name}`, + { config: new ReacordElementConfig(createNode, nodeProps) }, children, ) } + +export type ReacordHostElementProps = { + config: ReacordElementConfig +} + +// Any kind of element can go through the React reconciler. +// This class serves as a typesafe wrapper for creating a node +// and assigning props to an existing node. +// We can use `instanceof` to know for sure that the element is a Reacord element +export class ReacordElementConfig { + constructor(readonly create: () => Node, readonly props: Props) {} + + static parse(value: unknown): ReacordElementConfig { + if (value instanceof ReacordElementConfig) return value + const debugValue = inspect(value, { depth: 1 }) + throw new Error(`Expected ${ReacordElementConfig.name}, got ${debugValue}`) + } +} diff --git a/packages/reacord/library.new/core/reacord-instance-pool.ts b/packages/reacord/library.new/core/reacord-instance-pool.ts index ca2009b..f4d3d78 100644 --- a/packages/reacord/library.new/core/reacord-instance-pool.ts +++ b/packages/reacord/library.new/core/reacord-instance-pool.ts @@ -1,8 +1,5 @@ import type { ReactNode } from "react" -import { Container } from "../../helpers/container" -import type { MessagePayload } from "./make-message-payload" -import { makeMessagePayload } from "./make-message-payload" -import type { Node } from "./node" +import { Node } from "./node" import { reconciler } from "./reconciler" export type ReacordOptions = { @@ -33,7 +30,7 @@ export type ReacordInstanceOptions = { } export type ReacordMessageRenderer = { - update: (payload: MessagePayload) => Promise + update: (tree: Node) => Promise deactivate: () => Promise destroy: () => Promise } @@ -47,18 +44,18 @@ export class ReacordInstancePool { } create({ initialContent, renderer }: ReacordInstanceOptions) { - const nodes = new Container() + const root = new Node({}) - const render = async () => { + const render = async (tree: Node) => { try { - await renderer.update(makeMessagePayload(nodes.getItems())) + await renderer.update(tree) } catch (error) { console.error("Failed to update message.", error) } } const container = reconciler.createContainer( - { nodes, render }, + { root, render }, 0, // eslint-disable-next-line unicorn/no-null null, diff --git a/packages/reacord/library.new/core/reconciler.ts b/packages/reacord/library.new/core/reconciler.ts index 7bb8bd9..86dc2b8 100644 --- a/packages/reacord/library.new/core/reconciler.ts +++ b/packages/reacord/library.new/core/reconciler.ts @@ -1,13 +1,31 @@ +/* eslint-disable unicorn/prefer-modern-dom-apis */ import ReactReconciler from "react-reconciler" import { DefaultEventPriority } from "react-reconciler/constants" -import type { Container } from "../../helpers/container" -import type { Node, TextNode } from "./node" -import { makeNode, NodeRef } from "./node" +import type { Node } from "./node" +import type { ReacordHostElementProps } from "./reacord-element" +import { ReacordElementConfig } from "./reacord-element" +import { TextNode } from "./text-node" + +// technically elements of any shape can go through the reconciler, +// so I'm typing this as unknown to ensure we validate the props +// before using them +type ReconcilerProps = { + [_ in keyof ReacordHostElementProps]?: unknown +} + +type ReconcilerContainer = { + root: Node + + // We need to pass in a render callback, so the reconciler can tell us + // when it's done modifying elements, after which we'll update + // the message in Discord + render: (root: Node) => void +} export const reconciler = ReactReconciler< string, // Type - { node?: unknown }, // Props - { nodes: Container; render: () => void }, // Container + ReconcilerProps, // Props + ReconcilerContainer, // Container Node, // Instance TextNode, // TextInstance never, // SuspenseInstance @@ -28,43 +46,43 @@ export const reconciler = ReactReconciler< noTimeout: -1, createInstance(type, props) { - return NodeRef.unwrap(props.node) + return ReacordElementConfig.parse(props.config).create() }, createTextInstance(text) { - return makeNode("text", { text }) + return new TextNode({ text }) }, appendInitialChild(parent, child) { - parent.children?.add(child) + parent.add(child) }, appendChild(parent, child) { - parent.children?.add(child) + parent.add(child) }, appendChildToContainer(container, child) { - container.nodes.add(child) + container.root.add(child) }, insertBefore(parent, child, beforeChild) { - parent.children?.insertBefore(child, beforeChild) + parent.insertBefore(child, beforeChild) }, insertInContainerBefore(container, child, beforeChild) { - container.nodes.insertBefore(child, beforeChild) + container.root.insertBefore(child, beforeChild) }, removeChild(parent, child) { - parent.children?.remove(child) + parent.remove(child) }, removeChildFromContainer(container, child) { - container.nodes.remove(child) + container.root.remove(child) }, clearContainer(container) { - container.nodes.clear() + container.root.clear() }, commitTextUpdate(node, oldText, newText) { @@ -72,7 +90,7 @@ export const reconciler = ReactReconciler< }, commitUpdate(node, updatePayload, type, prevProps, nextProps) { - node.props = NodeRef.unwrap(nextProps.node).props + node.props = ReacordElementConfig.parse(nextProps.config).props }, prepareForCommit() { @@ -81,7 +99,7 @@ export const reconciler = ReactReconciler< }, resetAfterCommit(container) { - container.render() + container.render(container.root.clone()) }, finalizeInitialChildren() { diff --git a/packages/reacord/library.new/core/text-node.ts b/packages/reacord/library.new/core/text-node.ts new file mode 100644 index 0000000..165a626 --- /dev/null +++ b/packages/reacord/library.new/core/text-node.ts @@ -0,0 +1,3 @@ +import { Node } from "./node" + +export class TextNode extends Node<{ text: string }> {} diff --git a/packages/reacord/library.new/djs/channel-message-renderer.ts b/packages/reacord/library.new/djs/channel-message-renderer.ts index 73fc20f..26a6ab7 100644 --- a/packages/reacord/library.new/djs/channel-message-renderer.ts +++ b/packages/reacord/library.new/djs/channel-message-renderer.ts @@ -1,6 +1,7 @@ import type { Client, Message, TextBasedChannel } from "discord.js" import { AsyncQueue } from "../../helpers/async-queue" -import type { MessagePayload as MessagePayloadType } from "../core/make-message-payload" +import { makeMessageUpdatePayload } from "../core/make-message-payload" +import type { Node } from "../core/node" import type { ReacordMessageRenderer } from "../core/reacord-instance-pool" export class ChannelMessageRenderer implements ReacordMessageRenderer { @@ -14,8 +15,10 @@ export class ChannelMessageRenderer implements ReacordMessageRenderer { private readonly channelId: string, ) {} - update({ content, embeds, components }: MessagePayloadType) { + update(root: Node) { return this.queue.add(async () => { + const { content, embeds, components } = makeMessageUpdatePayload(root) + if (!this.active) { return } diff --git a/packages/reacord/scripts/discordjs-manual-test.tsx b/packages/reacord/scripts/discordjs-manual-test.tsx index 4e922b2..7b1c822 100644 --- a/packages/reacord/scripts/discordjs-manual-test.tsx +++ b/packages/reacord/scripts/discordjs-manual-test.tsx @@ -46,13 +46,16 @@ await createTest( "should show button text, emojis, and make automatic action rows", async (channel) => { const fruitEmojis = ["🍎", "🍊", "🍌", "🍉", "🍇", "🍓", "🍒", "🍍"] + + const FruitLabel = (props: { index: number }) => <>{props.index + 1} + reacord.send( channel.id, <> {Array.from({ length: 7 }, (_, i) => (