refactor with node classes again
node classes are great as generic containers, and extended classes are great for node identity with instanceof also realized that the NodeFactory is a detail of ReacordElement, so I moved it and renamed it to ReacordElementConfig
This commit is contained in:
@@ -1,27 +0,0 @@
|
|||||||
export class Container<T> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import React, { useState } from "react"
|
import React from "react"
|
||||||
import type { Except } from "type-fest"
|
import type { Except } from "type-fest"
|
||||||
import type { ButtonSharedProps } from "./button-shared-props"
|
import type { ButtonSharedProps } from "./button-shared-props"
|
||||||
import type { ComponentEvent } from "./component-event"
|
import type { ComponentEvent } from "./component-event"
|
||||||
import type { NodeBase } from "./node"
|
import { Node } from "./node"
|
||||||
import { makeNode } from "./node"
|
|
||||||
import { ReacordElement } from "./reacord-element"
|
import { ReacordElement } from "./reacord-element"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,16 +27,18 @@ export type ButtonProps = ButtonSharedProps & {
|
|||||||
*/
|
*/
|
||||||
export type ButtonClickEvent = ComponentEvent
|
export type ButtonClickEvent = ComponentEvent
|
||||||
|
|
||||||
export type ButtonNode = NodeBase<
|
|
||||||
"button",
|
|
||||||
Except<ButtonProps, "label"> & { customId: string }
|
|
||||||
>
|
|
||||||
|
|
||||||
export function Button({ label, ...props }: ButtonProps) {
|
export function Button({ label, ...props }: ButtonProps) {
|
||||||
const [customId] = useState(() => randomUUID())
|
|
||||||
return (
|
return (
|
||||||
<ReacordElement node={makeNode("button", { ...props, customId })}>
|
<ReacordElement
|
||||||
|
name="button"
|
||||||
|
createNode={() => new ButtonNode(props)}
|
||||||
|
nodeProps={props}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</ReacordElement>
|
</ReacordElement>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ButtonNode extends Node<Except<ButtonProps, "label">> {
|
||||||
|
readonly customId = randomUUID()
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,21 +5,21 @@ import type {
|
|||||||
} from "discord-api-types/v10"
|
} from "discord-api-types/v10"
|
||||||
import { ButtonStyle, ComponentType } from "discord-api-types/v10"
|
import { ButtonStyle, ComponentType } from "discord-api-types/v10"
|
||||||
import type { ButtonProps } from "./button"
|
import type { ButtonProps } from "./button"
|
||||||
|
import { ButtonNode } from "./button"
|
||||||
import type { Node } from "./node"
|
import type { Node } from "./node"
|
||||||
|
import { TextNode } from "./text-node"
|
||||||
|
|
||||||
export type MessagePayload = RESTPostAPIChannelMessageJSONBody
|
export type MessageUpdatePayload = RESTPostAPIChannelMessageJSONBody
|
||||||
|
|
||||||
export function makeMessagePayload(tree: readonly Node[]) {
|
export function makeMessageUpdatePayload(root: Node) {
|
||||||
const payload: MessagePayload = {}
|
const payload: MessageUpdatePayload = {}
|
||||||
|
|
||||||
const content = tree
|
const content = extractText(root, 1)
|
||||||
.map((item) => (item.type === "text" ? item.props.text : ""))
|
|
||||||
.join("")
|
|
||||||
if (content) {
|
if (content) {
|
||||||
payload.content = content
|
payload.content = content
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionRows = makeActionRows(tree)
|
const actionRows = makeActionRows(root)
|
||||||
if (actionRows.length > 0) {
|
if (actionRows.length > 0) {
|
||||||
payload.components = actionRows
|
payload.components = actionRows
|
||||||
}
|
}
|
||||||
@@ -27,11 +27,11 @@ export function makeMessagePayload(tree: readonly Node[]) {
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeActionRows(tree: readonly Node[]) {
|
function makeActionRows(root: Node) {
|
||||||
const actionRows: Array<APIActionRowComponent<APIButtonComponent>> = []
|
const actionRows: Array<APIActionRowComponent<APIButtonComponent>> = []
|
||||||
|
|
||||||
for (const node of tree) {
|
for (const node of root.children) {
|
||||||
if (node.type === "button") {
|
if (node instanceof ButtonNode) {
|
||||||
let currentRow = actionRows[actionRows.length - 1]
|
let currentRow = actionRows[actionRows.length - 1]
|
||||||
if (!currentRow || currentRow.components.length === 5) {
|
if (!currentRow || currentRow.components.length === 5) {
|
||||||
currentRow = {
|
currentRow = {
|
||||||
@@ -43,8 +43,8 @@ function makeActionRows(tree: readonly Node[]) {
|
|||||||
|
|
||||||
currentRow.components.push({
|
currentRow.components.push({
|
||||||
type: ComponentType.Button,
|
type: ComponentType.Button,
|
||||||
custom_id: node.props.customId,
|
custom_id: node.customId,
|
||||||
label: extractText(node.children.getItems()),
|
label: extractText(node, Number.POSITIVE_INFINITY),
|
||||||
emoji: { name: node.props.emoji },
|
emoji: { name: node.props.emoji },
|
||||||
style: translateButtonStyle(node.props.style ?? "secondary"),
|
style: translateButtonStyle(node.props.style ?? "secondary"),
|
||||||
disabled: node.props.disabled,
|
disabled: node.props.disabled,
|
||||||
@@ -55,14 +55,10 @@ function makeActionRows(tree: readonly Node[]) {
|
|||||||
return actionRows
|
return actionRows
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractText(tree: readonly Node[]): string {
|
function extractText(node: Node, depth: number): string {
|
||||||
return tree
|
if (node instanceof TextNode) return node.props.text
|
||||||
.map((item) => {
|
if (depth <= 0) return ""
|
||||||
return item.type === "text"
|
return node.children.map((child) => extractText(child, depth - 1)).join("")
|
||||||
? item.props.text
|
|
||||||
: extractText(item.children.getItems())
|
|
||||||
})
|
|
||||||
.join("")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function translateButtonStyle(style: NonNullable<ButtonProps["style"]>) {
|
function translateButtonStyle(style: NonNullable<ButtonProps["style"]>) {
|
||||||
|
|||||||
@@ -1,37 +1,33 @@
|
|||||||
import type { ButtonNode } from "./button"
|
export class Node<Props = unknown> {
|
||||||
import { Container } from "../../helpers/container"
|
private readonly _children: Node[] = []
|
||||||
|
|
||||||
export type NodeBase<Type extends string, Props> = {
|
constructor(public props: Props) {}
|
||||||
type: Type
|
|
||||||
props: Props
|
|
||||||
children: Container<Node>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Node = TextNode | ButtonNode | ActionRowNode
|
get children(): readonly Node[] {
|
||||||
|
return this._children
|
||||||
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 = maybeNodeRef as Object | null | undefined
|
|
||||||
throw new TypeError(
|
clear() {
|
||||||
`Expected ${NodeRef.name}, received instance of "${received?.constructor.name}"`,
|
this._children.splice(0)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
add(...nodes: Node[]) {
|
||||||
|
this._children.push(...nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(node: Node) {
|
||||||
|
const index = this._children.indexOf(node)
|
||||||
|
if (index !== -1) this._children.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
insertBefore(node: Node, beforeNode: Node) {
|
||||||
|
const index = this._children.indexOf(beforeNode)
|
||||||
|
if (index !== -1) this._children.splice(index, 0, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone(): this {
|
||||||
|
const cloned: this = new (this.constructor as any)(this.props)
|
||||||
|
cloned.add(...this.children.map((child) => child.clone()))
|
||||||
|
return cloned
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,43 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import React from "react"
|
import { createElement } from "react"
|
||||||
|
import { inspect } from "node:util"
|
||||||
import type { Node } from "./node"
|
import type { Node } from "./node"
|
||||||
import { NodeRef } from "./node"
|
|
||||||
|
|
||||||
export function ReacordElement({
|
export function ReacordElement<NodeProps = unknown>({
|
||||||
node,
|
name,
|
||||||
|
createNode,
|
||||||
|
nodeProps,
|
||||||
children,
|
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: NodeProps
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return React.createElement(
|
return createElement<ReacordHostElementProps>(
|
||||||
"reacord-element",
|
`reacord-${name}`,
|
||||||
{ node: new NodeRef(node) },
|
{ config: new ReacordElementConfig(createNode, nodeProps) },
|
||||||
children,
|
children,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReacordHostElementProps = {
|
||||||
|
config: ReacordElementConfig<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Props> {
|
||||||
|
constructor(readonly create: () => Node<Props>, readonly props: Props) {}
|
||||||
|
|
||||||
|
static parse(value: unknown): ReacordElementConfig<unknown> {
|
||||||
|
if (value instanceof ReacordElementConfig) return value
|
||||||
|
const debugValue = inspect(value, { depth: 1 })
|
||||||
|
throw new Error(`Expected ${ReacordElementConfig.name}, got ${debugValue}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { Container } from "../../helpers/container"
|
import { Node } from "./node"
|
||||||
import type { MessagePayload } from "./make-message-payload"
|
|
||||||
import { makeMessagePayload } from "./make-message-payload"
|
|
||||||
import type { Node } from "./node"
|
|
||||||
import { reconciler } from "./reconciler"
|
import { reconciler } from "./reconciler"
|
||||||
|
|
||||||
export type ReacordOptions = {
|
export type ReacordOptions = {
|
||||||
@@ -33,7 +30,7 @@ export type ReacordInstanceOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ReacordMessageRenderer = {
|
export type ReacordMessageRenderer = {
|
||||||
update: (payload: MessagePayload) => Promise<void>
|
update: (tree: Node) => Promise<void>
|
||||||
deactivate: () => Promise<void>
|
deactivate: () => Promise<void>
|
||||||
destroy: () => Promise<void>
|
destroy: () => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -47,18 +44,18 @@ export class ReacordInstancePool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create({ initialContent, renderer }: ReacordInstanceOptions) {
|
create({ initialContent, renderer }: ReacordInstanceOptions) {
|
||||||
const nodes = new Container<Node>()
|
const root = new Node({})
|
||||||
|
|
||||||
const render = async () => {
|
const render = async (tree: Node) => {
|
||||||
try {
|
try {
|
||||||
await renderer.update(makeMessagePayload(nodes.getItems()))
|
await renderer.update(tree)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update message.", error)
|
console.error("Failed to update message.", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = reconciler.createContainer(
|
const container = reconciler.createContainer(
|
||||||
{ nodes, render },
|
{ root, render },
|
||||||
0,
|
0,
|
||||||
// eslint-disable-next-line unicorn/no-null
|
// eslint-disable-next-line unicorn/no-null
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
|
/* eslint-disable unicorn/prefer-modern-dom-apis */
|
||||||
import ReactReconciler from "react-reconciler"
|
import ReactReconciler from "react-reconciler"
|
||||||
import { DefaultEventPriority } from "react-reconciler/constants"
|
import { DefaultEventPriority } from "react-reconciler/constants"
|
||||||
import type { Container } from "../../helpers/container"
|
import type { Node } from "./node"
|
||||||
import type { Node, TextNode } from "./node"
|
import type { ReacordHostElementProps } from "./reacord-element"
|
||||||
import { makeNode, NodeRef } from "./node"
|
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<
|
export const reconciler = ReactReconciler<
|
||||||
string, // Type
|
string, // Type
|
||||||
{ node?: unknown }, // Props
|
ReconcilerProps, // Props
|
||||||
{ nodes: Container<Node>; render: () => void }, // Container
|
ReconcilerContainer, // Container
|
||||||
Node, // Instance
|
Node, // Instance
|
||||||
TextNode, // TextInstance
|
TextNode, // TextInstance
|
||||||
never, // SuspenseInstance
|
never, // SuspenseInstance
|
||||||
@@ -28,43 +46,43 @@ export const reconciler = ReactReconciler<
|
|||||||
noTimeout: -1,
|
noTimeout: -1,
|
||||||
|
|
||||||
createInstance(type, props) {
|
createInstance(type, props) {
|
||||||
return NodeRef.unwrap(props.node)
|
return ReacordElementConfig.parse(props.config).create()
|
||||||
},
|
},
|
||||||
|
|
||||||
createTextInstance(text) {
|
createTextInstance(text) {
|
||||||
return makeNode("text", { text })
|
return new TextNode({ text })
|
||||||
},
|
},
|
||||||
|
|
||||||
appendInitialChild(parent, child) {
|
appendInitialChild(parent, child) {
|
||||||
parent.children?.add(child)
|
parent.add(child)
|
||||||
},
|
},
|
||||||
|
|
||||||
appendChild(parent, child) {
|
appendChild(parent, child) {
|
||||||
parent.children?.add(child)
|
parent.add(child)
|
||||||
},
|
},
|
||||||
|
|
||||||
appendChildToContainer(container, child) {
|
appendChildToContainer(container, child) {
|
||||||
container.nodes.add(child)
|
container.root.add(child)
|
||||||
},
|
},
|
||||||
|
|
||||||
insertBefore(parent, child, beforeChild) {
|
insertBefore(parent, child, beforeChild) {
|
||||||
parent.children?.insertBefore(child, beforeChild)
|
parent.insertBefore(child, beforeChild)
|
||||||
},
|
},
|
||||||
|
|
||||||
insertInContainerBefore(container, child, beforeChild) {
|
insertInContainerBefore(container, child, beforeChild) {
|
||||||
container.nodes.insertBefore(child, beforeChild)
|
container.root.insertBefore(child, beforeChild)
|
||||||
},
|
},
|
||||||
|
|
||||||
removeChild(parent, child) {
|
removeChild(parent, child) {
|
||||||
parent.children?.remove(child)
|
parent.remove(child)
|
||||||
},
|
},
|
||||||
|
|
||||||
removeChildFromContainer(container, child) {
|
removeChildFromContainer(container, child) {
|
||||||
container.nodes.remove(child)
|
container.root.remove(child)
|
||||||
},
|
},
|
||||||
|
|
||||||
clearContainer(container) {
|
clearContainer(container) {
|
||||||
container.nodes.clear()
|
container.root.clear()
|
||||||
},
|
},
|
||||||
|
|
||||||
commitTextUpdate(node, oldText, newText) {
|
commitTextUpdate(node, oldText, newText) {
|
||||||
@@ -72,7 +90,7 @@ export const reconciler = ReactReconciler<
|
|||||||
},
|
},
|
||||||
|
|
||||||
commitUpdate(node, updatePayload, type, prevProps, nextProps) {
|
commitUpdate(node, updatePayload, type, prevProps, nextProps) {
|
||||||
node.props = NodeRef.unwrap(nextProps.node).props
|
node.props = ReacordElementConfig.parse(nextProps.config).props
|
||||||
},
|
},
|
||||||
|
|
||||||
prepareForCommit() {
|
prepareForCommit() {
|
||||||
@@ -81,7 +99,7 @@ export const reconciler = ReactReconciler<
|
|||||||
},
|
},
|
||||||
|
|
||||||
resetAfterCommit(container) {
|
resetAfterCommit(container) {
|
||||||
container.render()
|
container.render(container.root.clone())
|
||||||
},
|
},
|
||||||
|
|
||||||
finalizeInitialChildren() {
|
finalizeInitialChildren() {
|
||||||
|
|||||||
3
packages/reacord/library.new/core/text-node.ts
Normal file
3
packages/reacord/library.new/core/text-node.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { Node } from "./node"
|
||||||
|
|
||||||
|
export class TextNode extends Node<{ text: string }> {}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Client, Message, TextBasedChannel } from "discord.js"
|
import type { Client, Message, TextBasedChannel } from "discord.js"
|
||||||
import { AsyncQueue } from "../../helpers/async-queue"
|
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"
|
import type { ReacordMessageRenderer } from "../core/reacord-instance-pool"
|
||||||
|
|
||||||
export class ChannelMessageRenderer implements ReacordMessageRenderer {
|
export class ChannelMessageRenderer implements ReacordMessageRenderer {
|
||||||
@@ -14,8 +15,10 @@ export class ChannelMessageRenderer implements ReacordMessageRenderer {
|
|||||||
private readonly channelId: string,
|
private readonly channelId: string,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
update({ content, embeds, components }: MessagePayloadType) {
|
update(root: Node) {
|
||||||
return this.queue.add(async () => {
|
return this.queue.add(async () => {
|
||||||
|
const { content, embeds, components } = makeMessageUpdatePayload(root)
|
||||||
|
|
||||||
if (!this.active) {
|
if (!this.active) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,13 +46,16 @@ await createTest(
|
|||||||
"should show button text, emojis, and make automatic action rows",
|
"should show button text, emojis, and make automatic action rows",
|
||||||
async (channel) => {
|
async (channel) => {
|
||||||
const fruitEmojis = ["🍎", "🍊", "🍌", "🍉", "🍇", "🍓", "🍒", "🍍"]
|
const fruitEmojis = ["🍎", "🍊", "🍌", "🍉", "🍇", "🍓", "🍒", "🍍"]
|
||||||
|
|
||||||
|
const FruitLabel = (props: { index: number }) => <>{props.index + 1}</>
|
||||||
|
|
||||||
reacord.send(
|
reacord.send(
|
||||||
channel.id,
|
channel.id,
|
||||||
<>
|
<>
|
||||||
{Array.from({ length: 7 }, (_, i) => (
|
{Array.from({ length: 7 }, (_, i) => (
|
||||||
<Button
|
<Button
|
||||||
key={i}
|
key={i}
|
||||||
label={String(i + 1)}
|
label={<FruitLabel index={i} />}
|
||||||
emoji={fruitEmojis[i % 6]}
|
emoji={fruitEmojis[i % 6]}
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user