some refactors, mainly splitting out action queue

This commit is contained in:
MapleLeaf
2021-12-22 22:51:07 -06:00
parent 4d89795d13
commit 7fef81d187
6 changed files with 161 additions and 153 deletions

View File

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

View File

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

View File

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

View File

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