some refactors, mainly splitting out action queue
This commit is contained in:
@@ -323,7 +323,7 @@ async function clickButton(index = 0) {
|
|||||||
filter: (interaction) => interaction.customId === customId,
|
filter: (interaction) => interaction.customId === customId,
|
||||||
time: 1000,
|
time: 1000,
|
||||||
})
|
})
|
||||||
await root.complete()
|
await root.done()
|
||||||
}
|
}
|
||||||
|
|
||||||
function createButtonInteraction(customId: string) {
|
function createButtonInteraction(customId: string) {
|
||||||
|
|||||||
52
src/action-queue.ts
Normal file
52
src/action-queue.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export type Action = {
|
||||||
|
id: string
|
||||||
|
priority: number
|
||||||
|
run: () => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionQueue {
|
||||||
|
private actions: Action[] = []
|
||||||
|
private runningPromise?: Promise<void>
|
||||||
|
|
||||||
|
add(action: Action) {
|
||||||
|
const lastAction = this.actions[this.actions.length - 1]
|
||||||
|
if (lastAction?.id === action.id) {
|
||||||
|
this.actions[this.actions.length - 1] = action
|
||||||
|
} else {
|
||||||
|
this.actions.push(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.actions.sort((a, b) => a.priority - b.priority)
|
||||||
|
|
||||||
|
this.runActions()
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.actions = []
|
||||||
|
}
|
||||||
|
|
||||||
|
done() {
|
||||||
|
return this.runningPromise ?? Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
private runActions() {
|
||||||
|
if (this.runningPromise) return
|
||||||
|
|
||||||
|
this.runningPromise = new Promise((resolve) => {
|
||||||
|
// using a microtask to allow multiple actions to be added synchronously
|
||||||
|
queueMicrotask(async () => {
|
||||||
|
let action: Action | undefined
|
||||||
|
while ((action = this.actions.shift())) {
|
||||||
|
try {
|
||||||
|
await action.run()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to run action:`, action)
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
this.runningPromise = undefined
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/channel-renderer.ts
Normal file
95
src/channel-renderer.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import type {
|
||||||
|
InteractionCollector,
|
||||||
|
Message,
|
||||||
|
MessageComponentInteraction,
|
||||||
|
MessageComponentType,
|
||||||
|
TextBasedChannels,
|
||||||
|
} from "discord.js"
|
||||||
|
import type { Action } from "./action-queue.js"
|
||||||
|
import { ActionQueue } from "./action-queue.js"
|
||||||
|
import type { MessageNode } from "./node-tree.js"
|
||||||
|
import { collectInteractionHandlers, getMessageOptions } from "./node-tree.js"
|
||||||
|
|
||||||
|
export class ChannelRenderer {
|
||||||
|
private channel: TextBasedChannels
|
||||||
|
private interactionCollector: InteractionCollector<MessageComponentInteraction>
|
||||||
|
private message?: Message
|
||||||
|
private tree?: MessageNode
|
||||||
|
private actions = new ActionQueue()
|
||||||
|
|
||||||
|
constructor(channel: TextBasedChannels) {
|
||||||
|
this.channel = channel
|
||||||
|
this.interactionCollector = this.createInteractionCollector()
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInteractionHandler(customId: string) {
|
||||||
|
if (!this.tree) return undefined
|
||||||
|
const handlers = collectInteractionHandlers(this.tree)
|
||||||
|
return handlers.find((handler) => handler.customId === customId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createInteractionCollector() {
|
||||||
|
const collector =
|
||||||
|
this.channel.createMessageComponentCollector<MessageComponentType>({
|
||||||
|
filter: (interaction) =>
|
||||||
|
!!this.getInteractionHandler(interaction.customId),
|
||||||
|
})
|
||||||
|
|
||||||
|
collector.on("collect", (interaction) => {
|
||||||
|
const handler = this.getInteractionHandler(interaction.customId)
|
||||||
|
if (handler?.type === "button" && interaction.isButton()) {
|
||||||
|
interaction.deferUpdate().catch(console.error)
|
||||||
|
handler.onClick(interaction)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return collector as InteractionCollector<MessageComponentInteraction>
|
||||||
|
}
|
||||||
|
|
||||||
|
render(node: MessageNode) {
|
||||||
|
this.actions.add(this.createUpdateMessageAction(node))
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.actions.clear()
|
||||||
|
this.actions.add(this.createDeleteMessageAction())
|
||||||
|
this.interactionCollector.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
done() {
|
||||||
|
return this.actions.done()
|
||||||
|
}
|
||||||
|
|
||||||
|
private createUpdateMessageAction(tree: MessageNode): Action {
|
||||||
|
return {
|
||||||
|
id: "updateMessage",
|
||||||
|
priority: 0,
|
||||||
|
run: async () => {
|
||||||
|
const options = getMessageOptions(tree)
|
||||||
|
|
||||||
|
// eslint-disable-next-line unicorn/prefer-ternary
|
||||||
|
if (this.message) {
|
||||||
|
this.message = await this.message.edit({
|
||||||
|
...options,
|
||||||
|
|
||||||
|
// need to ensure that the proper fields are erased if there's no content
|
||||||
|
// eslint-disable-next-line unicorn/no-null
|
||||||
|
content: options.content ?? null,
|
||||||
|
// eslint-disable-next-line unicorn/no-null
|
||||||
|
embeds: options.embeds ?? [],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.message = await this.channel.send(options)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDeleteMessageAction(): Action {
|
||||||
|
return {
|
||||||
|
id: "deleteMessage",
|
||||||
|
priority: 0,
|
||||||
|
run: () => this.message?.delete(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
/* eslint-disable unicorn/no-null */
|
/* eslint-disable unicorn/no-null */
|
||||||
import { inspect } from "node:util"
|
import { inspect } from "node:util"
|
||||||
import ReactReconciler from "react-reconciler"
|
import ReactReconciler from "react-reconciler"
|
||||||
|
import type { ChannelRenderer } from "./channel-renderer.js"
|
||||||
import { raise } from "./helpers/raise.js"
|
import { raise } from "./helpers/raise.js"
|
||||||
import type { MessageNode, Node, TextNode } from "./node-tree.js"
|
import type { MessageNode, Node, TextNode } from "./node-tree.js"
|
||||||
import type { MessageRenderer } from "./renderer.js"
|
|
||||||
|
|
||||||
type ElementTag = string
|
type ElementTag = string
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ type ChildSet = MessageNode
|
|||||||
export const reconciler = ReactReconciler<
|
export const reconciler = ReactReconciler<
|
||||||
string, // Type (jsx tag),
|
string, // Type (jsx tag),
|
||||||
Props, // Props,
|
Props, // Props,
|
||||||
MessageRenderer, // Container,
|
ChannelRenderer, // Container,
|
||||||
Node, // Instance,
|
Node, // Instance,
|
||||||
TextNode, // TextInstance,
|
TextNode, // TextInstance,
|
||||||
never, // SuspenseInstance,
|
never, // SuspenseInstance,
|
||||||
@@ -65,11 +65,11 @@ export const reconciler = ReactReconciler<
|
|||||||
childSet.children.push(child)
|
childSet.children.push(child)
|
||||||
},
|
},
|
||||||
|
|
||||||
finalizeContainerChildren: (container: MessageRenderer, children: ChildSet) =>
|
finalizeContainerChildren: (container: ChannelRenderer, children: ChildSet) =>
|
||||||
false,
|
false,
|
||||||
|
|
||||||
replaceContainerChildren: (
|
replaceContainerChildren: (
|
||||||
container: MessageRenderer,
|
container: ChannelRenderer,
|
||||||
children: ChildSet,
|
children: ChildSet,
|
||||||
) => {
|
) => {
|
||||||
container.render(children)
|
container.render(children)
|
||||||
|
|||||||
137
src/renderer.ts
137
src/renderer.ts
@@ -1,137 +0,0 @@
|
|||||||
import type {
|
|
||||||
InteractionCollector,
|
|
||||||
Message,
|
|
||||||
MessageComponentInteraction,
|
|
||||||
MessageComponentType,
|
|
||||||
TextBasedChannels,
|
|
||||||
} from "discord.js"
|
|
||||||
import type { MessageNode } from "./node-tree.js"
|
|
||||||
import { collectInteractionHandlers, getMessageOptions } from "./node-tree.js"
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| { type: "updateMessage"; tree: MessageNode }
|
|
||||||
| { type: "deleteMessage" }
|
|
||||||
| {
|
|
||||||
type: "interaction.deferUpdate"
|
|
||||||
interaction: MessageComponentInteraction
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MessageRenderer {
|
|
||||||
private channel: TextBasedChannels
|
|
||||||
private interactionCollector: InteractionCollector<MessageComponentInteraction>
|
|
||||||
private message?: Message
|
|
||||||
private tree?: MessageNode
|
|
||||||
private actions: Action[] = []
|
|
||||||
private runningPromise?: Promise<void>
|
|
||||||
|
|
||||||
constructor(channel: TextBasedChannels) {
|
|
||||||
this.channel = channel
|
|
||||||
this.interactionCollector =
|
|
||||||
this.createInteractionCollector() as InteractionCollector<MessageComponentInteraction>
|
|
||||||
}
|
|
||||||
|
|
||||||
private getInteractionHandler(customId: string) {
|
|
||||||
if (!this.tree) return undefined
|
|
||||||
const handlers = collectInteractionHandlers(this.tree)
|
|
||||||
return handlers.find((handler) => handler.customId === customId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private createInteractionCollector() {
|
|
||||||
const collector =
|
|
||||||
this.channel.createMessageComponentCollector<MessageComponentType>({
|
|
||||||
filter: (interaction) =>
|
|
||||||
!!this.getInteractionHandler(interaction.customId),
|
|
||||||
})
|
|
||||||
|
|
||||||
collector.on("collect", (interaction) => {
|
|
||||||
const handler = this.getInteractionHandler(interaction.customId)
|
|
||||||
if (handler?.type === "button" && interaction.isButton()) {
|
|
||||||
this.actions.unshift({ type: "interaction.deferUpdate", interaction })
|
|
||||||
handler.onClick(interaction)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return collector
|
|
||||||
}
|
|
||||||
|
|
||||||
render(node: MessageNode) {
|
|
||||||
this.addAction({
|
|
||||||
type: "updateMessage",
|
|
||||||
tree: node,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.actions = []
|
|
||||||
this.addAction({ type: "deleteMessage" })
|
|
||||||
this.interactionCollector.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
completion() {
|
|
||||||
return this.runningPromise ?? Promise.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
private addAction(action: Action) {
|
|
||||||
const lastAction = this.actions[this.actions.length - 1]
|
|
||||||
if (lastAction?.type === action.type) {
|
|
||||||
this.actions[this.actions.length - 1] = action
|
|
||||||
} else {
|
|
||||||
this.actions.push(action)
|
|
||||||
}
|
|
||||||
this.runActions()
|
|
||||||
}
|
|
||||||
|
|
||||||
private runActions() {
|
|
||||||
if (this.runningPromise) return
|
|
||||||
|
|
||||||
this.runningPromise = new Promise((resolve) => {
|
|
||||||
// using a microtask to allow multiple actions to be added synchronously
|
|
||||||
queueMicrotask(async () => {
|
|
||||||
let action: Action | undefined
|
|
||||||
while ((action = this.actions.shift())) {
|
|
||||||
try {
|
|
||||||
await this.runAction(action)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to run action:`, action)
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
this.runningPromise = undefined
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runAction(action: Action) {
|
|
||||||
if (action.type === "updateMessage") {
|
|
||||||
const options = getMessageOptions(action.tree)
|
|
||||||
// eslint-disable-next-line unicorn/prefer-ternary
|
|
||||||
if (this.message) {
|
|
||||||
this.message = await this.message.edit({
|
|
||||||
...options,
|
|
||||||
|
|
||||||
// need to ensure that the proper fields are erased if there's no content
|
|
||||||
// eslint-disable-next-line unicorn/no-null
|
|
||||||
content: options.content ?? null,
|
|
||||||
// components: options.components?.length
|
|
||||||
// ? options.components
|
|
||||||
// : null,
|
|
||||||
// eslint-disable-next-line unicorn/no-null
|
|
||||||
embeds: options.embeds ?? [],
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.message = await this.channel.send(options)
|
|
||||||
}
|
|
||||||
this.tree = action.tree
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.type === "deleteMessage") {
|
|
||||||
await this.message?.delete()
|
|
||||||
this.message = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.type === "interaction.deferUpdate") {
|
|
||||||
await action.interaction.deferUpdate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
20
src/root.ts
20
src/root.ts
@@ -1,26 +1,24 @@
|
|||||||
/* eslint-disable unicorn/no-null */
|
/* eslint-disable unicorn/no-null */
|
||||||
import type { TextBasedChannels } from "discord.js"
|
import type { TextBasedChannels } from "discord.js"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { reconciler } from "./reconciler"
|
import { ChannelRenderer } from "./channel-renderer.js"
|
||||||
import { MessageRenderer } from "./renderer"
|
import { reconciler } from "./reconciler.js"
|
||||||
|
|
||||||
export type ReacordRenderTarget = TextBasedChannels
|
|
||||||
|
|
||||||
export type ReacordRoot = ReturnType<typeof createRoot>
|
export type ReacordRoot = ReturnType<typeof createRoot>
|
||||||
|
|
||||||
export function createRoot(target: ReacordRenderTarget) {
|
export function createRoot(target: TextBasedChannels) {
|
||||||
const container = new MessageRenderer(target)
|
const renderer = new ChannelRenderer(target)
|
||||||
const containerId = reconciler.createContainer(container, 0, false, null)
|
const containerId = reconciler.createContainer(renderer, 0, false, null)
|
||||||
return {
|
return {
|
||||||
render: (content: ReactNode) => {
|
render: (content: ReactNode) => {
|
||||||
reconciler.updateContainer(content, containerId)
|
reconciler.updateContainer(content, containerId)
|
||||||
return container.completion()
|
return renderer.done()
|
||||||
},
|
},
|
||||||
destroy: () => {
|
destroy: () => {
|
||||||
reconciler.updateContainer(null, containerId)
|
reconciler.updateContainer(null, containerId)
|
||||||
container.destroy()
|
renderer.destroy()
|
||||||
return container.completion()
|
return renderer.done()
|
||||||
},
|
},
|
||||||
complete: () => container.completion(),
|
done: () => renderer.done(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user