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) => (
+