From 02808b75504f3e3a696c701b1ebdf67ca22c47a6 Mon Sep 17 00:00:00 2001 From: itsMapleLeaf <19603573+itsMapleLeaf@users.noreply.github.com> Date: Sat, 23 Jul 2022 18:29:16 -0500 Subject: [PATCH] split stuff up + handle immediate renders --- packages/reacord/library.new/discord-js.ts | 262 +++++------------- packages/reacord/library.new/main.ts | 2 + packages/reacord/library.new/message-tree.ts | 9 + packages/reacord/library.new/reacord.ts | 91 ++++++ packages/reacord/library.new/reconciler.ts | 125 +++++++++ .../reacord/scripts/discordjs-manual-test.tsx | 6 + 6 files changed, 304 insertions(+), 191 deletions(-) create mode 100644 packages/reacord/library.new/main.ts create mode 100644 packages/reacord/library.new/message-tree.ts create mode 100644 packages/reacord/library.new/reacord.ts create mode 100644 packages/reacord/library.new/reconciler.ts diff --git a/packages/reacord/library.new/discord-js.ts b/packages/reacord/library.new/discord-js.ts index 6645b7a..6ddf923 100644 --- a/packages/reacord/library.new/discord-js.ts +++ b/packages/reacord/library.new/discord-js.ts @@ -4,208 +4,88 @@ import type { Message, MessageEditOptions, MessageOptions, + TextBasedChannel, } from "discord.js" import type { ReactNode } from "react" -import ReactReconciler from "react-reconciler" -import { DefaultEventPriority } from "react-reconciler/constants" +import type { ReacordOptions } from "./reacord" +import { createReacordInstanceManager } from "./reacord" -export function createReacordDiscordJs(client: Client) { +export function createReacordDiscordJs( + client: Client, + options: ReacordOptions = {}, +) { + const manager = createReacordInstanceManager(options) return { send(channelId: string, initialContent?: ReactNode) { - let message: Message | undefined - - const tree: MessageTree = { - children: [], - render: async () => { - const messageOptions: MessageOptions & MessageEditOptions = { - content: tree.children.map((child) => child.text).join(""), - } - - try { - if (message) { - await message.edit(messageOptions) - return - } - - let channel = client.channels.cache.get(channelId) - if (!channel) { - channel = (await client.channels.fetch(channelId)) ?? undefined - } - if (!channel) { - throw new Error(`Channel ${channelId} not found`) - } - if (!channel.isTextBased()) { - throw new Error(`Channel ${channelId} is not a text channel`) - } - message = await channel.send(messageOptions) - } catch (error) { - console.error( - "Reacord encountered an error while rendering.", - error, - ) - } - }, - } - - const container = reconciler.createContainer( - tree, - 0, - // eslint-disable-next-line unicorn/no-null - null, - false, - // eslint-disable-next-line unicorn/no-null - null, - "reacord", - () => {}, - // eslint-disable-next-line unicorn/no-null - null, - ) - - const instance = { - render(content: ReactNode) { - reconciler.updateContainer(content, container) - }, - } - - if (initialContent !== undefined) { - instance.render(initialContent) - } - - return instance + const messageUpdater = createMessageUpdater() + return manager.createInstance(initialContent, async (tree) => { + const messageOptions: MessageOptions & MessageEditOptions = { + content: tree.children.map((child) => child.text).join(""), + } + const channel = await getTextChannel(client, channelId) + await messageUpdater.update(messageOptions, channel) + }) }, + reply(interaction: Interaction, initialContent?: ReactNode) {}, + ephemeralReply(interaction: Interaction, initialContent?: ReactNode) {}, } } -type MessageTree = { - children: TextNode[] - render: () => void +function createMessageUpdater() { + type UpdatePayload = { + options: MessageOptions & MessageEditOptions + channel: TextBasedChannel + } + + let message: Message | undefined + + const queue: UpdatePayload[] = [] + let queuePromise: Promise | undefined + + async function update( + options: MessageOptions & MessageEditOptions, + channel: TextBasedChannel, + ) { + queue.push({ options, channel }) + + if (queuePromise) { + return queuePromise + } + + queuePromise = runQueue() + try { + await queuePromise + } finally { + queuePromise = undefined + } + } + + async function runQueue() { + let payload: UpdatePayload | undefined + while ((payload = queue.shift())) { + if (message) { + await message.edit(payload.options) + } else { + message = await payload.channel.send(payload.options) + } + } + } + + return { update } } -type TextNode = { - type: "text" - text: string +async function getTextChannel(client: Client, channelId: string) { + let channel = client.channels.cache.get(channelId) + if (!channel) { + channel = (await client.channels.fetch(channelId)) ?? undefined + } + if (!channel) { + throw new Error(`Channel ${channelId} not found`) + } + if (!channel.isTextBased()) { + throw new Error(`Channel ${channelId} is not a text channel`) + } + return channel } - -const reconciler = ReactReconciler< - string, // Type - Record, // Props - MessageTree, // Container - never, // Instance - TextNode, // TextInstance - never, // SuspenseInstance - never, // HydratableInstance - never, // PublicInstance - {}, // HostContext - true, // UpdatePayload - never, // ChildSet - NodeJS.Timeout, // TimeoutHandle - -1 // NoTimeout ->({ - isPrimaryRenderer: true, - supportsMutation: true, - supportsHydration: false, - supportsPersistence: false, - scheduleTimeout: setTimeout, - cancelTimeout: clearTimeout, - noTimeout: -1, - - createInstance() { - throw new Error("Not implemented") - }, - - createTextInstance(text) { - return { type: "text", text } - }, - - appendInitialChild(parent, child) {}, - - appendChild(parentInstance, child) {}, - - appendChildToContainer(container, child) { - container.children.push(child) - }, - - insertBefore(parentInstance, child, beforeChild) {}, - - insertInContainerBefore(container, child, beforeChild) { - const index = container.children.indexOf(beforeChild) - if (index !== -1) container.children.splice(index, 0, child) - }, - - removeChild(parentInstance, child) {}, - - removeChildFromContainer(container, child) { - container.children = container.children.filter((c) => c !== child) - }, - - clearContainer(container) { - container.children = [] - }, - - commitTextUpdate(textInstance, oldText, newText) { - textInstance.text = newText - }, - - commitUpdate( - instance, - updatePayload, - type, - prevProps, - nextProps, - internalHandle, - ) {}, - - prepareForCommit() { - // eslint-disable-next-line unicorn/no-null - return null - }, - - resetAfterCommit(container) { - container.render() - }, - - finalizeInitialChildren() { - return false - }, - - prepareUpdate() { - return true - }, - - shouldSetTextContent() { - return false - }, - - getRootHostContext() { - return {} - }, - - getChildHostContext() { - return {} - }, - - getPublicInstance() { - throw new Error("Refs are not supported") - }, - - preparePortalMount() {}, - - getCurrentEventPriority() { - return DefaultEventPriority - }, - - getInstanceFromNode() { - return undefined - }, - - beforeActiveInstanceBlur() {}, - afterActiveInstanceBlur() {}, - prepareScopeUpdate() {}, - getInstanceFromScope() { - // eslint-disable-next-line unicorn/no-null - return null - }, - detachDeletedInstance() {}, -}) diff --git a/packages/reacord/library.new/main.ts b/packages/reacord/library.new/main.ts new file mode 100644 index 0000000..7043377 --- /dev/null +++ b/packages/reacord/library.new/main.ts @@ -0,0 +1,2 @@ +export { createReacordDiscordJs } from "./discord-js" +export { type ReacordInstance, type ReacordOptions } from "./reacord" diff --git a/packages/reacord/library.new/message-tree.ts b/packages/reacord/library.new/message-tree.ts new file mode 100644 index 0000000..badb0d3 --- /dev/null +++ b/packages/reacord/library.new/message-tree.ts @@ -0,0 +1,9 @@ +export type MessageTree = { + children: TextNode[] + render: () => void +} + +export type TextNode = { + type: "text" + text: string +} diff --git a/packages/reacord/library.new/reacord.ts b/packages/reacord/library.new/reacord.ts new file mode 100644 index 0000000..0fd18c7 --- /dev/null +++ b/packages/reacord/library.new/reacord.ts @@ -0,0 +1,91 @@ +import type { ReactNode } from "react" +import type { MessageTree } from "./message-tree" +import { reconciler } from "./reconciler" + +export type ReacordOptions = { + /** + * The max number of active instances. + * When this limit is exceeded, the oldest instances will be disabled. + */ + maxInstances?: number +} + +export type ReacordInstance = { + /** Render some JSX to this instance (edits the message) */ + render: (content: ReactNode) => void + + /** Remove this message */ + destroy: () => void + + /** + * Same as destroy, but keeps the message and disables the components on it. + * This prevents it from listening to user interactions. + */ + deactivate: () => void +} + +export function createReacordInstanceManager({ + maxInstances = 50, +}: ReacordOptions) { + const instances: ReacordInstance[] = [] + + function createInstance(...args: Parameters) { + const instance = createReacordInstance(...args) + instances.push(instance) + + if (instances.length > maxInstances) { + instances.shift()?.deactivate() + } + + return instance + } + + return { createInstance } +} + +function createReacordInstance( + initialContent: ReactNode, + render: (tree: MessageTree) => unknown, +): ReacordInstance { + const tree: MessageTree = { + children: [], + render: async () => { + try { + await render(tree) + } catch (error) { + console.error( + "Reacord encountered an error while updating the message.", + error, + ) + } + }, + } + + const container = reconciler.createContainer( + tree, + 0, + // eslint-disable-next-line unicorn/no-null + null, + false, + // eslint-disable-next-line unicorn/no-null + null, + "reacord", + () => {}, + // eslint-disable-next-line unicorn/no-null + null, + ) + + const instance: ReacordInstance = { + render(content: ReactNode) { + reconciler.updateContainer(content, container) + }, + destroy() {}, + deactivate() {}, + } + + if (initialContent !== undefined) { + instance.render(initialContent) + } + + return instance +} diff --git a/packages/reacord/library.new/reconciler.ts b/packages/reacord/library.new/reconciler.ts new file mode 100644 index 0000000..199ad6a --- /dev/null +++ b/packages/reacord/library.new/reconciler.ts @@ -0,0 +1,125 @@ +import ReactReconciler from "react-reconciler" +import { DefaultEventPriority } from "react-reconciler/constants" +import type { MessageTree, TextNode } from "./message-tree" + +export const reconciler = ReactReconciler< + string, + Record, + MessageTree, + never, + TextNode, + never, + never, + never, + {}, + true, + never, + NodeJS.Timeout, + -1 // NoTimeout +>({ + isPrimaryRenderer: true, + supportsMutation: true, + supportsHydration: false, + supportsPersistence: false, + scheduleTimeout: setTimeout, + cancelTimeout: clearTimeout, + noTimeout: -1, + + createInstance() { + throw new Error("Not implemented") + }, + + createTextInstance(text) { + return { type: "text", text } + }, + + appendInitialChild(parent, child) {}, + + appendChild(parentInstance, child) {}, + + appendChildToContainer(container, child) { + container.children.push(child) + }, + + insertBefore(parentInstance, child, beforeChild) {}, + + insertInContainerBefore(container, child, beforeChild) { + const index = container.children.indexOf(beforeChild) + if (index !== -1) container.children.splice(index, 0, child) + }, + + removeChild(parentInstance, child) {}, + + removeChildFromContainer(container, child) { + container.children = container.children.filter((c) => c !== child) + }, + + clearContainer(container) { + container.children = [] + }, + + commitTextUpdate(textInstance, oldText, newText) { + textInstance.text = newText + }, + + commitUpdate( + instance, + updatePayload, + type, + prevProps, + nextProps, + internalHandle, + ) {}, + + prepareForCommit() { + // eslint-disable-next-line unicorn/no-null + return null + }, + + resetAfterCommit(container) { + container.render() + }, + + finalizeInitialChildren() { + return false + }, + + prepareUpdate() { + return true + }, + + shouldSetTextContent() { + return false + }, + + getRootHostContext() { + return {} + }, + + getChildHostContext() { + return {} + }, + + getPublicInstance() { + throw new Error("Refs are not supported") + }, + + preparePortalMount() {}, + + getCurrentEventPriority() { + return DefaultEventPriority + }, + + getInstanceFromNode() { + return undefined + }, + + beforeActiveInstanceBlur() {}, + afterActiveInstanceBlur() {}, + prepareScopeUpdate() {}, + getInstanceFromScope() { + // eslint-disable-next-line unicorn/no-null + return null + }, + detachDeletedInstance() {}, +}) diff --git a/packages/reacord/scripts/discordjs-manual-test.tsx b/packages/reacord/scripts/discordjs-manual-test.tsx index 8c45c44..c9cd146 100644 --- a/packages/reacord/scripts/discordjs-manual-test.tsx +++ b/packages/reacord/scripts/discordjs-manual-test.tsx @@ -52,3 +52,9 @@ await createTest("basic", (channel) => { reacord.send(channel.id, ) }) + +await createTest("immediate renders", async (channel) => { + const instance = reacord.send(channel.id) + instance.render("hi world") + instance.render("hi moon") +})