refactor: interactive button
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
"test-watch": "vitest --watch",
|
||||
"coverage": "vitest --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"playground": "nodemon -x esmo ./playground/main.tsx"
|
||||
"playground": "nodemon --exec esmo --ext ts,tsx ./playground/main.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
|
||||
@@ -9,7 +9,7 @@ const client = new Client({
|
||||
intents: ["GUILDS"],
|
||||
})
|
||||
|
||||
const manager = new InstanceManager()
|
||||
const manager = InstanceManager.create(client)
|
||||
|
||||
createCommandHandler(client, [
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
MessageButtonStyle,
|
||||
MessageComponentInteraction,
|
||||
} from "discord.js"
|
||||
import { nanoid } from "nanoid"
|
||||
import React from "react"
|
||||
import { Node } from "../node.js"
|
||||
|
||||
@@ -22,6 +23,8 @@ export function Button(props: ButtonProps) {
|
||||
|
||||
export class ButtonNode extends Node {
|
||||
readonly name = "button"
|
||||
readonly customId = nanoid()
|
||||
|
||||
constructor(public props: ButtonProps) {
|
||||
super()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import type { CommandInteraction } from "discord.js"
|
||||
import type {
|
||||
Client,
|
||||
CommandInteraction,
|
||||
MessageComponentInteraction,
|
||||
} from "discord.js"
|
||||
import type { ReactNode } from "react"
|
||||
import type { OpaqueRoot } from "react-reconciler"
|
||||
import { reconciler } from "./reconciler.js"
|
||||
import { RootNode } from "./root-node.js"
|
||||
import { Renderer } from "./renderer.js"
|
||||
|
||||
export class InstanceManager {
|
||||
private instances = new Set<Instance>()
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static create(client: Client) {
|
||||
const manager = new InstanceManager()
|
||||
|
||||
client.on("interactionCreate", (interaction) => {
|
||||
if (!interaction.isMessageComponent()) return
|
||||
for (const instance of manager.instances) {
|
||||
if (instance.handleInteraction(interaction)) return
|
||||
}
|
||||
})
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
create(interaction: CommandInteraction) {
|
||||
const instance = new Instance(interaction)
|
||||
this.instances.add(instance)
|
||||
@@ -19,15 +38,19 @@ export class InstanceManager {
|
||||
}
|
||||
|
||||
class Instance {
|
||||
private rootNode: RootNode
|
||||
private renderer: Renderer
|
||||
private container: OpaqueRoot
|
||||
|
||||
constructor(interaction: CommandInteraction) {
|
||||
this.rootNode = new RootNode(interaction)
|
||||
this.container = reconciler.createContainer(this.rootNode, 0, false, {})
|
||||
this.renderer = new Renderer(interaction)
|
||||
this.container = reconciler.createContainer(this.renderer, 0, false, {})
|
||||
}
|
||||
|
||||
render(content: ReactNode) {
|
||||
reconciler.updateContainer(content, this.container)
|
||||
}
|
||||
|
||||
handleInteraction(interaction: MessageComponentInteraction) {
|
||||
return this.renderer.handleInteraction(interaction)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export abstract class Node {
|
||||
abstract get name(): string
|
||||
abstract props: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -3,20 +3,20 @@ import ReactReconciler from "react-reconciler"
|
||||
import { raise } from "../src/helpers/raise.js"
|
||||
import { ButtonNode } from "./components/button.js"
|
||||
import type { Node } from "./node.js"
|
||||
import type { RootNode } from "./root-node.js"
|
||||
import type { Renderer } from "./renderer.js"
|
||||
import { TextNode } from "./text-node.js"
|
||||
|
||||
const config: HostConfig<
|
||||
string, // Type,
|
||||
Record<string, unknown>, // Props,
|
||||
RootNode, // Container,
|
||||
Renderer, // Container,
|
||||
Node, // Instance,
|
||||
TextNode, // TextInstance,
|
||||
never, // SuspenseInstance,
|
||||
never, // HydratableInstance,
|
||||
never, // PublicInstance,
|
||||
{}, // HostContext,
|
||||
never, // UpdatePayload,
|
||||
true, // UpdatePayload,
|
||||
never, // ChildSet,
|
||||
number, // TimeoutHandle,
|
||||
number // NoTimeout,
|
||||
@@ -41,27 +41,29 @@ const config: HostConfig<
|
||||
createTextInstance: (text) => new TextNode(text),
|
||||
shouldSetTextContent: () => false,
|
||||
|
||||
clearContainer: (root) => {
|
||||
root.clear()
|
||||
clearContainer: (renderer) => {
|
||||
renderer.clear()
|
||||
},
|
||||
appendChildToContainer: (root, child) => {
|
||||
root.add(child)
|
||||
appendChildToContainer: (renderer, child) => {
|
||||
renderer.add(child)
|
||||
},
|
||||
removeChildFromContainer: (root, child) => {
|
||||
root.remove(child)
|
||||
removeChildFromContainer: (renderer, child) => {
|
||||
renderer.remove(child)
|
||||
},
|
||||
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
prepareUpdate: () => null,
|
||||
commitUpdate: () => {},
|
||||
prepareUpdate: () => true,
|
||||
commitUpdate: (node, payload, type, oldProps, newProps) => {
|
||||
node.props = newProps
|
||||
},
|
||||
commitTextUpdate: (node, oldText, newText) => {
|
||||
node.text = newText
|
||||
},
|
||||
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
prepareForCommit: () => null,
|
||||
resetAfterCommit: (root) => {
|
||||
root.render()
|
||||
resetAfterCommit: (renderer) => {
|
||||
renderer.render()
|
||||
},
|
||||
|
||||
preparePortalMount: () => raise("Portals are not supported"),
|
||||
|
||||
94
src.new/renderer.ts
Normal file
94
src.new/renderer.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type {
|
||||
CommandInteraction,
|
||||
MessageComponentInteraction,
|
||||
MessageOptions,
|
||||
} from "discord.js"
|
||||
import { MessageActionRow } from "discord.js"
|
||||
import { last } from "../src/helpers/last.js"
|
||||
import { toUpper } from "../src/helpers/to-upper.js"
|
||||
import { ButtonNode } from "./components/button.js"
|
||||
import type { Node } from "./node.js"
|
||||
import { TextNode } from "./text-node.js"
|
||||
|
||||
export class Renderer {
|
||||
private nodes = new Set<Node | TextNode>()
|
||||
private componentInteraction?: MessageComponentInteraction
|
||||
|
||||
constructor(private interaction: CommandInteraction) {}
|
||||
|
||||
add(child: Node | TextNode) {
|
||||
this.nodes.add(child)
|
||||
}
|
||||
|
||||
remove(child: Node | TextNode) {
|
||||
this.nodes.delete(child)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.nodes.clear()
|
||||
}
|
||||
|
||||
render() {
|
||||
const options = this.getMessageOptions()
|
||||
if (this.componentInteraction) {
|
||||
this.componentInteraction.update(options).catch(console.error)
|
||||
this.componentInteraction = undefined
|
||||
} else if (this.interaction.replied) {
|
||||
this.interaction.editReply(options).catch(console.error)
|
||||
} else {
|
||||
this.interaction.reply(options).catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
handleInteraction(interaction: MessageComponentInteraction) {
|
||||
if (interaction.isButton()) {
|
||||
this.componentInteraction = interaction
|
||||
this.getButtonCallback(interaction.customId)?.(interaction)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private getMessageOptions(): MessageOptions {
|
||||
let content = ""
|
||||
let components: MessageActionRow[] = []
|
||||
|
||||
for (const child of this.nodes) {
|
||||
if (child instanceof TextNode) {
|
||||
content += child.text
|
||||
}
|
||||
|
||||
if (child instanceof ButtonNode) {
|
||||
let actionRow = last(components)
|
||||
if (
|
||||
!actionRow ||
|
||||
actionRow.components.length >= 5 ||
|
||||
actionRow.components[0]?.type === "SELECT_MENU"
|
||||
) {
|
||||
actionRow = new MessageActionRow()
|
||||
components.push(actionRow)
|
||||
}
|
||||
|
||||
actionRow.addComponents({
|
||||
type: "BUTTON",
|
||||
customId: child.customId,
|
||||
style: toUpper(child.props.style ?? "secondary"),
|
||||
disabled: child.props.disabled,
|
||||
emoji: child.props.emoji,
|
||||
label: child.props.label,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { content, components }
|
||||
}
|
||||
|
||||
private getButtonCallback(customId: string) {
|
||||
for (const child of this.nodes) {
|
||||
if (child instanceof ButtonNode && child.customId === customId) {
|
||||
return child.props.onClick
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { CommandInteraction, MessageOptions } from "discord.js"
|
||||
import { MessageActionRow } from "discord.js"
|
||||
import { nanoid } from "nanoid"
|
||||
import { last } from "../src/helpers/last.js"
|
||||
import { toUpper } from "../src/helpers/to-upper.js"
|
||||
import { ButtonNode } from "./components/button.js"
|
||||
import { Node } from "./node.js"
|
||||
import { TextNode } from "./text-node.js"
|
||||
|
||||
export class RootNode extends Node {
|
||||
readonly name = "root"
|
||||
private children = new Set<Node>()
|
||||
|
||||
constructor(private interaction: CommandInteraction) {
|
||||
super()
|
||||
}
|
||||
|
||||
add(child: Node) {
|
||||
this.children.add(child)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.children.clear()
|
||||
}
|
||||
|
||||
remove(child: Node) {
|
||||
this.children.delete(child)
|
||||
}
|
||||
|
||||
render() {
|
||||
this.interaction.reply(this.getMessageOptions()).catch(console.error)
|
||||
}
|
||||
|
||||
private getMessageOptions(): MessageOptions {
|
||||
let content = ""
|
||||
let components: MessageActionRow[] = []
|
||||
|
||||
for (const child of this.children) {
|
||||
if (child instanceof TextNode) {
|
||||
content += child.text
|
||||
}
|
||||
|
||||
if (child instanceof ButtonNode) {
|
||||
let actionRow = last(components)
|
||||
if (
|
||||
!actionRow ||
|
||||
actionRow.components.length >= 5 ||
|
||||
actionRow.components[0]?.type === "SELECT_MENU"
|
||||
) {
|
||||
actionRow = new MessageActionRow()
|
||||
components.push(actionRow)
|
||||
}
|
||||
|
||||
actionRow.addComponents({
|
||||
type: "BUTTON",
|
||||
customId: nanoid(),
|
||||
style: toUpper(child.props.style ?? "secondary"),
|
||||
disabled: child.props.disabled,
|
||||
emoji: child.props.emoji,
|
||||
label: child.props.label,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { content, components }
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import { Node } from "./node.js"
|
||||
|
||||
export class TextNode extends Node {
|
||||
export class TextNode {
|
||||
readonly name = "text"
|
||||
constructor(public text: string) {
|
||||
super()
|
||||
}
|
||||
constructor(public text: string) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user