simplify node structure + convert to message payload in core
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import React from "react"
|
||||
import React, { useState } from "react"
|
||||
import type { Except } from "type-fest"
|
||||
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 type { NodeBase } from "./node"
|
||||
import { makeNode } from "./node"
|
||||
import { ReacordElement } from "./reacord-element"
|
||||
|
||||
/**
|
||||
@@ -28,24 +28,16 @@ export type ButtonProps = ButtonSharedProps & {
|
||||
*/
|
||||
export type ButtonClickEvent = ComponentEvent
|
||||
|
||||
export type ButtonNode = NodeBase<
|
||||
"button",
|
||||
Except<ButtonProps, "label"> & { customId: string }
|
||||
>
|
||||
|
||||
export function Button({ label, ...props }: ButtonProps) {
|
||||
const [customId] = useState(() => randomUUID())
|
||||
return (
|
||||
<ReacordElement createNode={() => new ButtonNode(props)} nodeProps={props}>
|
||||
<ReacordElement node={makeNode("button", { ...props, customId })}>
|
||||
{label}
|
||||
</ReacordElement>
|
||||
)
|
||||
}
|
||||
|
||||
export class ButtonNode implements Node<ButtonProps> {
|
||||
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("")
|
||||
}
|
||||
}
|
||||
|
||||
76
packages/reacord/library.new/make-message-payload.ts
Normal file
76
packages/reacord/library.new/make-message-payload.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
APIActionRowComponent,
|
||||
APIButtonComponent,
|
||||
RESTPostAPIChannelMessageJSONBody,
|
||||
} from "discord-api-types/v10"
|
||||
import { ButtonStyle, ComponentType } from "discord-api-types/v10"
|
||||
import type { ButtonProps } from "./button"
|
||||
import type { Node } from "./node"
|
||||
|
||||
export type MessagePayload = RESTPostAPIChannelMessageJSONBody
|
||||
|
||||
export function makeMessagePayload(tree: readonly Node[]) {
|
||||
const payload: MessagePayload = {}
|
||||
|
||||
const content = tree
|
||||
.map((item) => (item.type === "text" ? item.props.text : ""))
|
||||
.join("")
|
||||
if (content) {
|
||||
payload.content = content
|
||||
}
|
||||
|
||||
const actionRows = makeActionRows(tree)
|
||||
if (actionRows.length > 0) {
|
||||
payload.components = actionRows
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function makeActionRows(tree: readonly Node[]) {
|
||||
const actionRows: Array<APIActionRowComponent<APIButtonComponent>> = []
|
||||
|
||||
for (const node of tree) {
|
||||
if (node.type === "button") {
|
||||
let currentRow = actionRows[actionRows.length - 1]
|
||||
if (!currentRow || currentRow.components.length === 5) {
|
||||
currentRow = {
|
||||
type: ComponentType.ActionRow,
|
||||
components: [],
|
||||
}
|
||||
actionRows.push(currentRow)
|
||||
}
|
||||
|
||||
currentRow.components.push({
|
||||
type: ComponentType.Button,
|
||||
custom_id: node.props.customId,
|
||||
label: extractText(node.children.getItems()),
|
||||
emoji: { name: node.props.emoji },
|
||||
style: translateButtonStyle(node.props.style ?? "secondary"),
|
||||
disabled: node.props.disabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 translateButtonStyle(style: NonNullable<ButtonProps["style"]>) {
|
||||
const styleMap = {
|
||||
primary: ButtonStyle.Primary,
|
||||
secondary: ButtonStyle.Secondary,
|
||||
danger: ButtonStyle.Danger,
|
||||
success: ButtonStyle.Success,
|
||||
} as const
|
||||
return styleMap[style]
|
||||
}
|
||||
@@ -1,32 +1,37 @@
|
||||
import type { Container } from "./container"
|
||||
import type { ButtonNode } from "./button"
|
||||
import { Container } from "./container"
|
||||
|
||||
export type NodeContainer = Container<Node<unknown>>
|
||||
|
||||
export type Node<Props> = {
|
||||
props?: Props
|
||||
children?: NodeContainer
|
||||
export type NodeBase<Type extends string, Props> = {
|
||||
type: Type
|
||||
props: Props
|
||||
children: Container<Node>
|
||||
}
|
||||
|
||||
export class TextNode implements Node<{ text: string }> {
|
||||
props: { text: string }
|
||||
constructor(text: string) {
|
||||
this.props = { text }
|
||||
}
|
||||
}
|
||||
export type Node = TextNode | ButtonNode | ActionRowNode
|
||||
|
||||
export class NodeDefinition<Props> {
|
||||
static parse(value: unknown): NodeDefinition<unknown> {
|
||||
if (value instanceof NodeDefinition) {
|
||||
return value
|
||||
export type TextNode = NodeBase<"text", { text: string }>
|
||||
|
||||
export type ActionRowNode = NodeBase<"actionRow", {}>
|
||||
|
||||
export const makeNode = <Type extends Node["type"]>(
|
||||
type: Type,
|
||||
props: Extract<Node, { type: Type }>["props"],
|
||||
) =>
|
||||
({ type, props, children: new Container() } as Extract<Node, { type: Type }>)
|
||||
|
||||
/** A wrapper for ensuring we're actually working with a real node
|
||||
* inside the React reconciler
|
||||
*/
|
||||
export class NodeRef {
|
||||
constructor(public readonly node: Node) {}
|
||||
|
||||
static unwrap(maybeNodeRef: unknown): Node {
|
||||
if (maybeNodeRef instanceof NodeRef) {
|
||||
return maybeNodeRef.node
|
||||
}
|
||||
const received = value as Object | null | undefined
|
||||
const received = maybeNodeRef as Object | null | undefined
|
||||
throw new TypeError(
|
||||
`Expected ${NodeDefinition.name}, received instance of ${received?.constructor.name}`,
|
||||
`Expected ${NodeRef.name}, received instance of "${received?.constructor.name}"`,
|
||||
)
|
||||
}
|
||||
|
||||
constructor(
|
||||
public readonly create: () => Node<Props>,
|
||||
public readonly props: Props,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import type {
|
||||
Client,
|
||||
Interaction,
|
||||
Message,
|
||||
MessageEditOptions,
|
||||
MessageOptions,
|
||||
TextBasedChannel,
|
||||
} from "discord.js"
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"
|
||||
import type { Client, Interaction, Message, TextBasedChannel } from "discord.js"
|
||||
import { 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 { MessagePayload as MessagePayloadType } from "./make-message-payload"
|
||||
import type {
|
||||
ReacordMessageRenderer,
|
||||
ReacordOptions,
|
||||
@@ -47,51 +38,19 @@ class ChannelMessageRenderer implements ReacordMessageRenderer {
|
||||
private readonly channelId: string,
|
||||
) {}
|
||||
|
||||
update(nodes: ReadonlyArray<Node<unknown>>) {
|
||||
const rows: Array<ActionRowBuilder<ButtonBuilder>> = []
|
||||
|
||||
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 instanceof TextNode ? node.props.text : ""))
|
||||
.join(""),
|
||||
|
||||
components: rows.length > 0 ? rows : undefined,
|
||||
}
|
||||
|
||||
update({ content, embeds, components }: MessagePayloadType) {
|
||||
return this.queue.add(async () => {
|
||||
if (!this.active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.message) {
|
||||
await this.message.edit(options)
|
||||
await this.message.edit({ content, embeds, components })
|
||||
return
|
||||
}
|
||||
|
||||
const channel = await this.getChannel()
|
||||
this.message = await channel.send(options)
|
||||
this.message = await channel.send({ content, embeds, components })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import type { ReactNode } from "react"
|
||||
import React from "react"
|
||||
import type { Node } from "./node"
|
||||
import { NodeDefinition } from "./node"
|
||||
import { NodeRef } from "./node"
|
||||
|
||||
export function ReacordElement<Props>({
|
||||
export function ReacordElement({
|
||||
node,
|
||||
children,
|
||||
createNode,
|
||||
nodeProps,
|
||||
}: {
|
||||
createNode: () => Node<Props>
|
||||
nodeProps: Props
|
||||
node: Node
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return React.createElement(
|
||||
"reacord-element",
|
||||
{ definition: new NodeDefinition(createNode, nodeProps) },
|
||||
{ node: new NodeRef(node) },
|
||||
children,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { Container } from "./container"
|
||||
import type { Node, NodeContainer } from "./node"
|
||||
import type { MessagePayload } from "./make-message-payload"
|
||||
import { makeMessagePayload } from "./make-message-payload"
|
||||
import type { Node } from "./node"
|
||||
import { reconciler } from "./reconciler"
|
||||
|
||||
export type ReacordOptions = {
|
||||
@@ -31,7 +33,7 @@ export type ReacordInstanceOptions = {
|
||||
}
|
||||
|
||||
export type ReacordMessageRenderer = {
|
||||
update: (nodes: ReadonlyArray<Node<unknown>>) => Promise<void>
|
||||
update: (payload: MessagePayload) => Promise<void>
|
||||
deactivate: () => Promise<void>
|
||||
destroy: () => Promise<void>
|
||||
}
|
||||
@@ -45,11 +47,11 @@ export class ReacordInstancePool {
|
||||
}
|
||||
|
||||
create({ initialContent, renderer }: ReacordInstanceOptions) {
|
||||
const nodes: NodeContainer = new Container()
|
||||
const nodes = new Container<Node>()
|
||||
|
||||
const render = async () => {
|
||||
try {
|
||||
await renderer.update(nodes.getItems())
|
||||
await renderer.update(makeMessagePayload(nodes.getItems()))
|
||||
} catch (error) {
|
||||
console.error("Failed to update message.", error)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import ReactReconciler from "react-reconciler"
|
||||
import { DefaultEventPriority } from "react-reconciler/constants"
|
||||
import type { Node, NodeContainer } from "./node"
|
||||
import { NodeDefinition, TextNode } from "./node"
|
||||
import type { Container } from "./container"
|
||||
import type { Node, TextNode } from "./node"
|
||||
import { makeNode, NodeRef } from "./node"
|
||||
|
||||
export const reconciler = ReactReconciler<
|
||||
string, // Type
|
||||
{ definition?: unknown }, // Props
|
||||
{ nodes: NodeContainer; render: () => void }, // Container
|
||||
Node<unknown>, // Instance
|
||||
{ node?: unknown }, // Props
|
||||
{ nodes: Container<Node>; render: () => void }, // Container
|
||||
Node, // Instance
|
||||
TextNode, // TextInstance
|
||||
never, // SuspenseInstance
|
||||
never, // HydratableInstance
|
||||
@@ -27,11 +28,11 @@ export const reconciler = ReactReconciler<
|
||||
noTimeout: -1,
|
||||
|
||||
createInstance(type, props) {
|
||||
return NodeDefinition.parse(props.definition).create()
|
||||
return NodeRef.unwrap(props.node)
|
||||
},
|
||||
|
||||
createTextInstance(text) {
|
||||
return new TextNode(text)
|
||||
return makeNode("text", { text })
|
||||
},
|
||||
|
||||
appendInitialChild(parent, child) {
|
||||
@@ -71,7 +72,7 @@ export const reconciler = ReactReconciler<
|
||||
},
|
||||
|
||||
commitUpdate(node, updatePayload, type, prevProps, nextProps) {
|
||||
node.props = NodeDefinition.parse(nextProps.definition).props
|
||||
node.props = NodeRef.unwrap(nextProps.node).props
|
||||
},
|
||||
|
||||
prepareForCommit() {
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"@types/node": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-reconciler": "^0.28.0",
|
||||
"discord-api-types": "^0.36.3",
|
||||
"react-reconciler": "^0.29.0",
|
||||
"rxjs": "^7.5.6"
|
||||
},
|
||||
|
||||
@@ -41,16 +41,26 @@ const createTest = async (
|
||||
await block(channel)
|
||||
}
|
||||
|
||||
await createTest("components", "test 'dem buttons", async (channel) => {
|
||||
reacord.send(
|
||||
channel.id,
|
||||
<>
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<Button key={i} label={String(i + 1)} onClick={() => {}} />
|
||||
))}
|
||||
</>,
|
||||
)
|
||||
})
|
||||
await createTest(
|
||||
"buttons",
|
||||
"should show button text, emojis, and make automatic action rows",
|
||||
async (channel) => {
|
||||
const fruitEmojis = ["🍎", "🍊", "🍌", "🍉", "🍇", "🍓", "🍒", "🍍"]
|
||||
reacord.send(
|
||||
channel.id,
|
||||
<>
|
||||
{Array.from({ length: 7 }, (_, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
label={String(i + 1)}
|
||||
emoji={fruitEmojis[i % 6]}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
))}
|
||||
</>,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
await createTest("basic", "should update over time", (channel) => {
|
||||
function Timer() {
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -32,6 +32,7 @@ importers:
|
||||
'@types/react': '*'
|
||||
'@types/react-reconciler': ^0.28.0
|
||||
c8: ^7.12.0
|
||||
discord-api-types: ^0.36.3
|
||||
discord.js: ^14.0.3
|
||||
dotenv: ^16.0.1
|
||||
lodash-es: ^4.17.21
|
||||
@@ -51,6 +52,7 @@ importers:
|
||||
'@types/node': 18.0.6
|
||||
'@types/react': 18.0.15
|
||||
'@types/react-reconciler': 0.28.0
|
||||
discord-api-types: 0.36.3
|
||||
react-reconciler: 0.29.0_react@18.2.0
|
||||
rxjs: 7.5.6
|
||||
devDependencies:
|
||||
@@ -4305,7 +4307,6 @@ packages:
|
||||
|
||||
/discord-api-types/0.36.3:
|
||||
resolution: {integrity: sha512-bz/NDyG0KBo/tY14vSkrwQ/n3HKPf87a0WFW/1M9+tXYK+vp5Z5EksawfCWo2zkAc6o7CClc0eff1Pjrqznlwg==}
|
||||
dev: true
|
||||
|
||||
/discord.js/14.0.3:
|
||||
resolution: {integrity: sha512-wH/VQl4CqN8/+dcXEtYis1iurqxGlDpEe0O4CqH5FGqZGIjVpTdtK0STXXx7bVNX8MT/0GvLZLkmO/5gLDWZVg==}
|
||||
|
||||
Reference in New Issue
Block a user