refactor: interactive button

This commit is contained in:
MapleLeaf
2021-12-25 01:52:55 -06:00
parent 99430e0edc
commit 18bcf4828c
9 changed files with 145 additions and 93 deletions

View File

@@ -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": "*",

View File

@@ -9,7 +9,7 @@ const client = new Client({
intents: ["GUILDS"],
})
const manager = new InstanceManager()
const manager = InstanceManager.create(client)
createCommandHandler(client, [
{

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
export abstract class Node {
abstract get name(): string
abstract props: Record<string, unknown>
}

View File

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

View File

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

View File

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