diff --git a/packages/reacord/library.new/button-shared-props.ts b/packages/reacord/library.new/button-shared-props.ts new file mode 100644 index 0000000..4218af1 --- /dev/null +++ b/packages/reacord/library.new/button-shared-props.ts @@ -0,0 +1,24 @@ +import type { ReactNode } from "react" + +/** + * Common props between button-like components + * @category Button + */ +export type ButtonSharedProps = { + /** The text on the button. Rich formatting (markdown) is not supported here. */ + label?: ReactNode + + /** When true, the button will be slightly faded, and cannot be clicked. */ + 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>`. + * + * 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 +} diff --git a/packages/reacord/library.new/button.tsx b/packages/reacord/library.new/button.tsx new file mode 100644 index 0000000..f56cfe0 --- /dev/null +++ b/packages/reacord/library.new/button.tsx @@ -0,0 +1,51 @@ +import { randomUUID } from "node:crypto" +import React from "react" +import type { ButtonSharedProps } from "./button-shared-props" +import type { ComponentEvent } from "./component-event" +import { Container } from "./container" +import type { Node, NodeContainer } from "./node" +import { TextNode } from "./node" +import { ReacordElement } from "./reacord-element" + +/** + * @category Button + */ +export type ButtonProps = ButtonSharedProps & { + /** + * The style determines the color of the button and signals intent. + * @see https://discord.com/developers/docs/interactions/message-components#button-object-button-styles + */ + style?: "primary" | "secondary" | "success" | "danger" + + /** + * Happens when a user clicks the button. + */ + onClick: (event: ButtonClickEvent) => void +} + +/** + * @category Button + */ +export type ButtonClickEvent = ComponentEvent + +export function Button({ label, ...props }: ButtonProps) { + return ( + new ButtonNode(props)} nodeProps={props}> + {label} + + ) +} + +export class ButtonNode implements Node { + readonly children: NodeContainer = new Container() + readonly customId = randomUUID() + + constructor(readonly props: ButtonProps) {} + + get label(): string { + return this.children + .getItems() + .map((child) => (child instanceof TextNode ? child.props.text : "")) + .join("") + } +} diff --git a/packages/reacord/library.new/component-event.ts b/packages/reacord/library.new/component-event.ts new file mode 100644 index 0000000..7b10665 --- /dev/null +++ b/packages/reacord/library.new/component-event.ts @@ -0,0 +1,113 @@ +import type { ReactNode } from "react" +import type { ReacordInstance } from "./reacord-instance-pool" + +/** + * @category Component Event + */ +export type ComponentEvent = { + /** + * The message associated with this event. + * For example: with a button click, + * this is the message that the button is on. + * @see https://discord.com/developers/docs/resources/channel#message-object + */ + message: MessageInfo + + /** + * The channel that this event occurred in. + * @see https://discord.com/developers/docs/resources/channel#channel-object + */ + channel: ChannelInfo + + /** + * The user that triggered this event. + * @see https://discord.com/developers/docs/resources/user#user-object + */ + user: UserInfo + + /** + * The guild that this event occurred in. + * @see https://discord.com/developers/docs/resources/guild#guild-object + */ + guild?: GuildInfo + + /** + * Create a new reply to this event. + */ + reply(content?: ReactNode): ReacordInstance + + /** + * Create an ephemeral reply to this event, + * shown only to the user who triggered it. + */ + ephemeralReply(content?: ReactNode): ReacordInstance +} + +/** + * @category Component Event + */ +export type ChannelInfo = { + id: string + name?: string + topic?: string + nsfw?: boolean + lastMessageId?: string + ownerId?: string + parentId?: string + rateLimitPerUser?: number +} + +/** + * @category Component Event + */ +export type MessageInfo = { + id: string + channelId: string + authorId: UserInfo + member?: GuildMemberInfo + content: string + timestamp: string + editedTimestamp?: string + tts: boolean + mentionEveryone: boolean + /** The IDs of mentioned users */ + mentions: string[] +} + +/** + * @category Component Event + */ +export type GuildInfo = { + id: string + name: string + member: GuildMemberInfo +} + +/** + * @category Component Event + */ +export type GuildMemberInfo = { + id: string + nick?: string + displayName: string + avatarUrl?: string + displayAvatarUrl: string + roles: string[] + color: number + joinedAt?: string + premiumSince?: string + pending?: boolean + communicationDisabledUntil?: string +} + +/** + * @category Component Event + */ +export type UserInfo = { + id: string + username: string + discriminator: string + tag: string + avatarUrl: string + accentColor?: number +} diff --git a/packages/reacord/library.new/node.ts b/packages/reacord/library.new/node.ts index 275f376..138ade0 100644 --- a/packages/reacord/library.new/node.ts +++ b/packages/reacord/library.new/node.ts @@ -1,20 +1,32 @@ -export type Node = { - readonly type: string - readonly props?: Record - children?: Node[] - getText?: () => string +import type { Container } from "./container" + +export type NodeContainer = Container> + +export type Node = { + props?: Props + children?: NodeContainer } -export class TextNode implements Node { - readonly type = "text" - - constructor(private text: string) {} - - getText() { - return this.text - } - - setText(text: string) { - this.text = text +export class TextNode implements Node<{ text: string }> { + props: { text: string } + constructor(text: string) { + this.props = { text } } } + +export class NodeDefinition { + static parse(value: unknown): NodeDefinition { + if (value instanceof NodeDefinition) { + return value + } + const received = value as Object | null | undefined + throw new TypeError( + `Expected ${NodeDefinition.name}, received instance of ${received?.constructor.name}`, + ) + } + + constructor( + public readonly create: () => Node, + public readonly props: Props, + ) {} +} diff --git a/packages/reacord/library.new/reacord-discord-js.ts b/packages/reacord/library.new/reacord-discord-js.ts index c98ab72..6ae48d4 100644 --- a/packages/reacord/library.new/reacord-discord-js.ts +++ b/packages/reacord/library.new/reacord-discord-js.ts @@ -6,9 +6,13 @@ import type { MessageOptions, TextBasedChannel, } from "discord.js" +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js" import type { ReactNode } from "react" import { AsyncQueue } from "./async-queue" +import type { ButtonProps } from "./button" +import { ButtonNode } from "./button" import type { Node } from "./node" +import { TextNode } from "./node" import type { ReacordMessageRenderer, ReacordOptions, @@ -43,9 +47,37 @@ class ChannelMessageRenderer implements ReacordMessageRenderer { private readonly channelId: string, ) {} - update(nodes: readonly Node[]) { + update(nodes: ReadonlyArray>) { + const rows: Array> = [] + + for (const node of nodes) { + if (node instanceof ButtonNode) { + let currentRow = rows[rows.length - 1] + if (!currentRow || currentRow.components.length === 5) { + currentRow = new ActionRowBuilder() + rows.push(currentRow) + } + + currentRow.addComponents( + new ButtonBuilder({ + label: node.label, + customId: node.customId, + emoji: node.props.emoji, + disabled: node.props.disabled, + style: node.props.style + ? getButtonStyle(node.props.style) + : ButtonStyle.Secondary, + }), + ) + } + } + const options: MessageOptions & MessageEditOptions = { - content: nodes.map((node) => node.getText?.() || "").join(""), + content: nodes + .map((node) => (node instanceof TextNode ? node.props.text : "")) + .join(""), + + components: rows.length > 0 ? rows : undefined, } return this.queue.add(async () => { @@ -95,3 +127,13 @@ class ChannelMessageRenderer implements ReacordMessageRenderer { return (this.channel = channel) } } + +function getButtonStyle(style: NonNullable) { + const styleMap = { + primary: ButtonStyle.Primary, + secondary: ButtonStyle.Secondary, + danger: ButtonStyle.Danger, + success: ButtonStyle.Success, + } as const + return styleMap[style] +} diff --git a/packages/reacord/library.new/reacord-element.ts b/packages/reacord/library.new/reacord-element.ts new file mode 100644 index 0000000..83bcb5d --- /dev/null +++ b/packages/reacord/library.new/reacord-element.ts @@ -0,0 +1,20 @@ +import type { ReactNode } from "react" +import React from "react" +import type { Node } from "./node" +import { NodeDefinition } from "./node" + +export function ReacordElement({ + children, + createNode, + nodeProps, +}: { + createNode: () => Node + nodeProps: Props + children?: ReactNode +}) { + return React.createElement( + "reacord-element", + { definition: new NodeDefinition(createNode, nodeProps) }, + children, + ) +} diff --git a/packages/reacord/library.new/reacord-instance-pool.ts b/packages/reacord/library.new/reacord-instance-pool.ts index 1d5ddec..1d5df7f 100644 --- a/packages/reacord/library.new/reacord-instance-pool.ts +++ b/packages/reacord/library.new/reacord-instance-pool.ts @@ -1,6 +1,6 @@ import type { ReactNode } from "react" import { Container } from "./container" -import type { Node } from "./node" +import type { Node, NodeContainer } from "./node" import { reconciler } from "./reconciler" export type ReacordOptions = { @@ -31,7 +31,7 @@ export type ReacordInstanceOptions = { } export type ReacordMessageRenderer = { - update: (nodes: readonly Node[]) => Promise + update: (nodes: ReadonlyArray>) => Promise deactivate: () => Promise destroy: () => Promise } @@ -45,7 +45,7 @@ export class ReacordInstancePool { } create({ initialContent, renderer }: ReacordInstanceOptions) { - const nodes = new Container() + const nodes: NodeContainer = new Container() const render = async () => { try { diff --git a/packages/reacord/library.new/reconciler.ts b/packages/reacord/library.new/reconciler.ts index 53e00fa..212db41 100644 --- a/packages/reacord/library.new/reconciler.ts +++ b/packages/reacord/library.new/reconciler.ts @@ -1,14 +1,13 @@ import ReactReconciler from "react-reconciler" import { DefaultEventPriority } from "react-reconciler/constants" -import type { Container } from "./container" -import type { Node } from "./node" -import { TextNode } from "./node" +import type { Node, NodeContainer } from "./node" +import { NodeDefinition, TextNode } from "./node" export const reconciler = ReactReconciler< string, // Type - Record, // Props - { nodes: Container; render: () => void }, // Container - never, // Instance + { definition?: unknown }, // Props + { nodes: NodeContainer; render: () => void }, // Container + Node, // Instance TextNode, // TextInstance never, // SuspenseInstance never, // HydratableInstance @@ -27,29 +26,37 @@ export const reconciler = ReactReconciler< cancelTimeout: clearTimeout, noTimeout: -1, - createInstance() { - throw new Error("Not implemented") + createInstance(type, props) { + return NodeDefinition.parse(props.definition).create() }, createTextInstance(text) { return new TextNode(text) }, - appendInitialChild(parent, child) {}, + appendInitialChild(parent, child) { + parent.children?.add(child) + }, - appendChild(parentInstance, child) {}, + appendChild(parent, child) { + parent.children?.add(child) + }, appendChildToContainer(container, child) { container.nodes.add(child) }, - insertBefore(parentInstance, child, beforeChild) {}, + insertBefore(parent, child, beforeChild) { + parent.children?.insertBefore(child, beforeChild) + }, insertInContainerBefore(container, child, beforeChild) { container.nodes.insertBefore(child, beforeChild) }, - removeChild(parentInstance, child) {}, + removeChild(parent, child) { + parent.children?.remove(child) + }, removeChildFromContainer(container, child) { container.nodes.remove(child) @@ -59,18 +66,13 @@ export const reconciler = ReactReconciler< container.nodes.clear() }, - commitTextUpdate(textInstance, oldText, newText) { - textInstance.setText(newText) + commitTextUpdate(node, oldText, newText) { + node.props.text = newText }, - commitUpdate( - instance, - updatePayload, - type, - prevProps, - nextProps, - internalHandle, - ) {}, + commitUpdate(node, updatePayload, type, prevProps, nextProps) { + node.props = NodeDefinition.parse(nextProps.definition).props + }, prepareForCommit() { // eslint-disable-next-line unicorn/no-null diff --git a/packages/reacord/scripts/discordjs-manual-test.tsx b/packages/reacord/scripts/discordjs-manual-test.tsx index 475f48f..133cbb5 100644 --- a/packages/reacord/scripts/discordjs-manual-test.tsx +++ b/packages/reacord/scripts/discordjs-manual-test.tsx @@ -6,7 +6,7 @@ import prettyMilliseconds from "pretty-ms" import React, { useEffect, useState } from "react" import { raise } from "../helpers/raise" import { waitFor } from "../helpers/wait-for" -import { ReacordDiscordJs } from "../library.new/main" +import { Button, ReacordDiscordJs } from "../library.new/main" const client = new Client({ intents: IntentsBitField.Flags.Guilds }) const reacord = new ReacordDiscordJs(client) @@ -41,6 +41,17 @@ const createTest = async ( await block(channel) } +await createTest("components", "test 'dem buttons", async (channel) => { + reacord.send( + channel.id, + <> + {Array.from({ length: 6 }, (_, i) => ( +