refactor: interactive button
This commit is contained in:
@@ -21,7 +21,7 @@
|
|||||||
"test-watch": "vitest --watch",
|
"test-watch": "vitest --watch",
|
||||||
"coverage": "vitest --coverage",
|
"coverage": "vitest --coverage",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"playground": "nodemon -x esmo ./playground/main.tsx"
|
"playground": "nodemon --exec esmo --ext ts,tsx ./playground/main.tsx"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const client = new Client({
|
|||||||
intents: ["GUILDS"],
|
intents: ["GUILDS"],
|
||||||
})
|
})
|
||||||
|
|
||||||
const manager = new InstanceManager()
|
const manager = InstanceManager.create(client)
|
||||||
|
|
||||||
createCommandHandler(client, [
|
createCommandHandler(client, [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
MessageButtonStyle,
|
MessageButtonStyle,
|
||||||
MessageComponentInteraction,
|
MessageComponentInteraction,
|
||||||
} from "discord.js"
|
} from "discord.js"
|
||||||
|
import { nanoid } from "nanoid"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { Node } from "../node.js"
|
import { Node } from "../node.js"
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ export function Button(props: ButtonProps) {
|
|||||||
|
|
||||||
export class ButtonNode extends Node {
|
export class ButtonNode extends Node {
|
||||||
readonly name = "button"
|
readonly name = "button"
|
||||||
|
readonly customId = nanoid()
|
||||||
|
|
||||||
constructor(public props: ButtonProps) {
|
constructor(public props: ButtonProps) {
|
||||||
super()
|
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 { ReactNode } from "react"
|
||||||
import type { OpaqueRoot } from "react-reconciler"
|
import type { OpaqueRoot } from "react-reconciler"
|
||||||
import { reconciler } from "./reconciler.js"
|
import { reconciler } from "./reconciler.js"
|
||||||
import { RootNode } from "./root-node.js"
|
import { Renderer } from "./renderer.js"
|
||||||
|
|
||||||
export class InstanceManager {
|
export class InstanceManager {
|
||||||
private instances = new Set<Instance>()
|
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) {
|
create(interaction: CommandInteraction) {
|
||||||
const instance = new Instance(interaction)
|
const instance = new Instance(interaction)
|
||||||
this.instances.add(instance)
|
this.instances.add(instance)
|
||||||
@@ -19,15 +38,19 @@ export class InstanceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Instance {
|
class Instance {
|
||||||
private rootNode: RootNode
|
private renderer: Renderer
|
||||||
private container: OpaqueRoot
|
private container: OpaqueRoot
|
||||||
|
|
||||||
constructor(interaction: CommandInteraction) {
|
constructor(interaction: CommandInteraction) {
|
||||||
this.rootNode = new RootNode(interaction)
|
this.renderer = new Renderer(interaction)
|
||||||
this.container = reconciler.createContainer(this.rootNode, 0, false, {})
|
this.container = reconciler.createContainer(this.renderer, 0, false, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
render(content: ReactNode) {
|
render(content: ReactNode) {
|
||||||
reconciler.updateContainer(content, this.container)
|
reconciler.updateContainer(content, this.container)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleInteraction(interaction: MessageComponentInteraction) {
|
||||||
|
return this.renderer.handleInteraction(interaction)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export abstract class Node {
|
export abstract class Node {
|
||||||
abstract get name(): string
|
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 { raise } from "../src/helpers/raise.js"
|
||||||
import { ButtonNode } from "./components/button.js"
|
import { ButtonNode } from "./components/button.js"
|
||||||
import type { Node } from "./node.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"
|
import { TextNode } from "./text-node.js"
|
||||||
|
|
||||||
const config: HostConfig<
|
const config: HostConfig<
|
||||||
string, // Type,
|
string, // Type,
|
||||||
Record<string, unknown>, // Props,
|
Record<string, unknown>, // Props,
|
||||||
RootNode, // Container,
|
Renderer, // Container,
|
||||||
Node, // Instance,
|
Node, // Instance,
|
||||||
TextNode, // TextInstance,
|
TextNode, // TextInstance,
|
||||||
never, // SuspenseInstance,
|
never, // SuspenseInstance,
|
||||||
never, // HydratableInstance,
|
never, // HydratableInstance,
|
||||||
never, // PublicInstance,
|
never, // PublicInstance,
|
||||||
{}, // HostContext,
|
{}, // HostContext,
|
||||||
never, // UpdatePayload,
|
true, // UpdatePayload,
|
||||||
never, // ChildSet,
|
never, // ChildSet,
|
||||||
number, // TimeoutHandle,
|
number, // TimeoutHandle,
|
||||||
number // NoTimeout,
|
number // NoTimeout,
|
||||||
@@ -41,27 +41,29 @@ const config: HostConfig<
|
|||||||
createTextInstance: (text) => new TextNode(text),
|
createTextInstance: (text) => new TextNode(text),
|
||||||
shouldSetTextContent: () => false,
|
shouldSetTextContent: () => false,
|
||||||
|
|
||||||
clearContainer: (root) => {
|
clearContainer: (renderer) => {
|
||||||
root.clear()
|
renderer.clear()
|
||||||
},
|
},
|
||||||
appendChildToContainer: (root, child) => {
|
appendChildToContainer: (renderer, child) => {
|
||||||
root.add(child)
|
renderer.add(child)
|
||||||
},
|
},
|
||||||
removeChildFromContainer: (root, child) => {
|
removeChildFromContainer: (renderer, child) => {
|
||||||
root.remove(child)
|
renderer.remove(child)
|
||||||
},
|
},
|
||||||
|
|
||||||
// eslint-disable-next-line unicorn/no-null
|
// eslint-disable-next-line unicorn/no-null
|
||||||
prepareUpdate: () => null,
|
prepareUpdate: () => true,
|
||||||
commitUpdate: () => {},
|
commitUpdate: (node, payload, type, oldProps, newProps) => {
|
||||||
|
node.props = newProps
|
||||||
|
},
|
||||||
commitTextUpdate: (node, oldText, newText) => {
|
commitTextUpdate: (node, oldText, newText) => {
|
||||||
node.text = newText
|
node.text = newText
|
||||||
},
|
},
|
||||||
|
|
||||||
// eslint-disable-next-line unicorn/no-null
|
// eslint-disable-next-line unicorn/no-null
|
||||||
prepareForCommit: () => null,
|
prepareForCommit: () => null,
|
||||||
resetAfterCommit: (root) => {
|
resetAfterCommit: (renderer) => {
|
||||||
root.render()
|
renderer.render()
|
||||||
},
|
},
|
||||||
|
|
||||||
preparePortalMount: () => raise("Portals are not supported"),
|
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 {
|
||||||
|
|
||||||
export class TextNode extends Node {
|
|
||||||
readonly name = "text"
|
readonly name = "text"
|
||||||
constructor(public text: string) {
|
constructor(public text: string) {}
|
||||||
super()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user