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:
itsMapleLeaf
2022-07-26 09:19:59 -05:00
parent 67b1f45a8f
commit 4e3f1cc7cb
10 changed files with 137 additions and 122 deletions

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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"]>) {

View File

@@ -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 }> clear() {
this._children.splice(0)
}
export type ActionRowNode = NodeBase<"actionRow", {}> add(...nodes: Node[]) {
this._children.push(...nodes)
}
export const makeNode = <Type extends Node["type"]>( remove(node: Node) {
type: Type, const index = this._children.indexOf(node)
props: Extract<Node, { type: Type }>["props"], if (index !== -1) this._children.splice(index, 1)
) => }
({ type, props, children: new Container() } as Extract<Node, { type: Type }>)
/** A wrapper for ensuring we're actually working with a real node insertBefore(node: Node, beforeNode: Node) {
* inside the React reconciler const index = this._children.indexOf(beforeNode)
*/ if (index !== -1) this._children.splice(index, 0, node)
export class NodeRef { }
constructor(public readonly node: Node) {}
static unwrap(maybeNodeRef: unknown): Node { clone(): this {
if (maybeNodeRef instanceof NodeRef) { const cloned: this = new (this.constructor as any)(this.props)
return maybeNodeRef.node cloned.add(...this.children.map((child) => child.clone()))
} return cloned
const received = maybeNodeRef as Object | null | undefined
throw new TypeError(
`Expected ${NodeRef.name}, received instance of "${received?.constructor.name}"`,
)
} }
} }

View File

@@ -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}`)
}
}

View File

@@ -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,

View File

@@ -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() {

View File

@@ -0,0 +1,3 @@
import { Node } from "./node"
export class TextNode extends Node<{ text: string }> {}

View File

@@ -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
} }

View File

@@ -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={() => {}}
/> />