remove old sources

This commit is contained in:
MapleLeaf
2021-12-25 13:08:34 -06:00
parent 8ced531144
commit d14086a60c
35 changed files with 116 additions and 1181 deletions

View File

@@ -1,30 +0,0 @@
import { setTimeout } from "node:timers/promises"
import { expect, test } from "vitest"
import { ActionQueue } from "./action-queue.js"
test("action queue", async () => {
const queue = new ActionQueue()
let results: string[] = []
queue.add({
id: "a",
priority: 1,
run: async () => {
await setTimeout(100)
results.push("a")
},
})
queue.add({
id: "b",
priority: 0,
run: async () => {
await setTimeout(50)
results.push("b")
},
})
expect(results).toEqual([])
await queue.done()
expect(results).toEqual(["b", "a"])
})

View File

@@ -1,45 +0,0 @@
export type Action = {
id: string
priority: number
run: () => unknown
}
export class ActionQueue {
private actions: Action[] = []
private runningPromise?: Promise<void>
add(action: Action) {
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
})
})
}
}

74
src/button.tsx Normal file
View File

@@ -0,0 +1,74 @@
import type {
ButtonInteraction,
CacheType,
EmojiResolvable,
MessageButtonStyle,
MessageComponentInteraction,
MessageOptions,
} from "discord.js"
import { MessageActionRow } from "discord.js"
import { nanoid } from "nanoid"
import React from "react"
import { ReacordElement } from "./element.js"
import { last } from "./helpers/last.js"
import { toUpper } from "./helpers/to-upper.js"
import { Node } from "./node.js"
export type ButtonProps = {
label?: string
style?: Exclude<Lowercase<MessageButtonStyle>, "link">
disabled?: boolean
emoji?: EmojiResolvable
onClick: (interaction: ButtonInteraction) => void
}
export function Button(props: ButtonProps) {
return (
<ReacordElement props={props} createNode={() => new ButtonNode(props)} />
)
}
class ButtonNode extends Node<ButtonProps> {
private customId = nanoid()
private get buttonOptions() {
return {
type: "BUTTON",
customId: this.customId,
style: toUpper(this.props.style ?? "secondary"),
disabled: this.props.disabled,
emoji: this.props.emoji,
label: this.props.label,
} as const
}
override modifyMessageOptions(options: MessageOptions): void {
options.components ??= []
let actionRow = last(options.components)
if (
!actionRow ||
actionRow.components.length >= 5 ||
actionRow.components[0]?.type === "SELECT_MENU"
) {
actionRow = new MessageActionRow()
options.components.push(actionRow)
}
if (actionRow instanceof MessageActionRow) {
actionRow.addComponents(this.buttonOptions)
} else {
actionRow.components.push(this.buttonOptions)
}
}
override handleInteraction(
interaction: MessageComponentInteraction<CacheType>,
) {
if (interaction.isButton() && interaction.customId === this.customId) {
this.props.onClick(interaction)
return true
}
}
}

View File

@@ -1,98 +0,0 @@
import type {
InteractionCollector,
Message,
MessageComponentInteraction,
MessageComponentType,
TextBasedChannels,
} from "discord.js"
import type { Action } from "./action-queue.js"
import { ActionQueue } from "./action-queue.js"
import { collectInteractionHandlers } from "./collect-interaction-handlers"
import { createMessageOptions } from "./create-message-options"
import type { MessageNode } from "./node.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(node: MessageNode): Action {
return {
id: "updateMessage",
priority: 0,
run: async () => {
const options = createMessageOptions(node)
// 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)
}
this.tree = node
},
}
}
private createDeleteMessageAction(): Action {
return {
id: "deleteMessage",
priority: 0,
run: () => this.message?.delete(),
}
}
}

View File

@@ -1,20 +0,0 @@
import type { ButtonInteraction } from "discord.js"
import type { Node } from "./node"
type InteractionHandler = {
type: "button"
customId: string
onClick: (interaction: ButtonInteraction) => void
}
export function collectInteractionHandlers(node: Node): InteractionHandler[] {
if (node.type === "button") {
return [{ type: "button", customId: node.customId, onClick: node.onClick }]
}
if ("children" in node) {
return node.children.flatMap(collectInteractionHandlers)
}
return []
}

View File

@@ -1,13 +0,0 @@
import React from "react"
export type ActionRowProps = {
children: React.ReactNode
}
export function ActionRow(props: ActionRowProps) {
return (
<reacord-element createNode={() => ({ type: "actionRow", children: [] })}>
{props.children}
</reacord-element>
)
}

View File

@@ -1,32 +0,0 @@
import type {
ButtonInteraction,
EmojiResolvable,
MessageButtonStyle,
} from "discord.js"
import { nanoid } from "nanoid"
import React from "react"
export type ButtonStyle = Exclude<Lowercase<MessageButtonStyle>, "link">
export type ButtonProps = {
style?: ButtonStyle
emoji?: EmojiResolvable
disabled?: boolean
onClick: (interaction: ButtonInteraction) => void
children?: React.ReactNode
}
export function Button(props: ButtonProps) {
return (
<reacord-element
createNode={() => ({
...props,
type: "button",
children: [],
customId: nanoid(),
})}
>
{props.children}
</reacord-element>
)
}

View File

@@ -1,17 +0,0 @@
import React from "react"
export type EmbedFieldProps = {
name: string
children: React.ReactNode
inline?: boolean
}
export function EmbedField(props: EmbedFieldProps) {
return (
<reacord-element
createNode={() => ({ ...props, type: "embedField", children: [] })}
>
{props.children}
</reacord-element>
)
}

View File

@@ -1,32 +0,0 @@
import type { ColorResolvable } from "discord.js"
import type { ReactNode } from "react"
import React from "react"
export type EmbedProps = {
title?: string
color?: ColorResolvable
url?: string
timestamp?: Date | number | string
imageUrl?: string
thumbnailUrl?: string
author?: {
name: string
url?: string
iconUrl?: string
}
footer?: {
text: string
iconUrl?: string
}
children?: ReactNode
}
export function Embed(props: EmbedProps) {
return (
<reacord-element
createNode={() => ({ ...props, type: "embed", children: [] })}
>
{props.children}
</reacord-element>
)
}

View File

@@ -1,14 +0,0 @@
import type { ReactNode } from "react"
import React from "react"
export type TextProps = {
children?: ReactNode
}
export function Text(props: TextProps) {
return (
<reacord-element createNode={() => ({ type: "textElement", children: [] })}>
{props.children}
</reacord-element>
)
}

27
src/container.ts Normal file
View File

@@ -0,0 +1,27 @@
export class Container<T> {
private items: T[] = []
add(...items: T[]) {
this.items.push(...items)
}
addBefore(item: T, before: T) {
let index = this.items.indexOf(before)
if (index === -1) {
index = this.items.length
}
this.items.splice(index, 0, item)
}
remove(toRemove: T) {
this.items = this.items.filter((item) => item !== toRemove)
}
clear() {
this.items = []
}
[Symbol.iterator]() {
return this.items[Symbol.iterator]()
}
}

View File

@@ -1,125 +0,0 @@
import type {
BaseMessageComponentOptions,
MessageActionRowOptions,
MessageEmbedOptions,
MessageOptions,
} from "discord.js"
import { last } from "./helpers/last.js"
import { toUpper } from "./helpers/to-upper.js"
import type { EmbedNode, MessageNode, Node } from "./node"
export function createMessageOptions(node: MessageNode): MessageOptions {
if (node.children.length === 0) {
// can't send an empty message
return { content: "_ _" }
}
const options: MessageOptions = {}
for (const child of node.children) {
if (child.type === "text" || child.type === "textElement") {
options.content = `${options.content ?? ""}${getNodeText(child)}`
}
if (child.type === "embed") {
options.embeds ??= []
options.embeds.push(getEmbedOptions(child))
}
if (child.type === "actionRow") {
options.components ??= []
options.components.push({
type: "ACTION_ROW",
components: [],
})
addActionRowItems(options.components, child.children)
}
if (child.type === "button") {
options.components ??= []
addActionRowItems(options.components, [child])
}
}
if (!options.content && !options.embeds?.length) {
options.content = "_ _"
}
return options
}
function getNodeText(node: Node): string | undefined {
if (node.type === "text") {
return node.text
}
if (node.type === "textElement") {
return node.children.map(getNodeText).join("")
}
}
function getEmbedOptions(node: EmbedNode) {
const options: MessageEmbedOptions = {
title: node.title,
color: node.color,
url: node.url,
timestamp: node.timestamp ? new Date(node.timestamp) : undefined,
image: { url: node.imageUrl },
thumbnail: { url: node.thumbnailUrl },
description: node.children.map(getNodeText).join(""),
author: node.author
? { ...node.author, iconURL: node.author.iconUrl }
: undefined,
footer: node.footer
? { text: node.footer.text, iconURL: node.footer.iconUrl }
: undefined,
}
for (const child of node.children) {
if (child.type === "embedField") {
options.fields ??= []
options.fields.push({
name: child.name,
value: child.children.map(getNodeText).join("") || "_ _",
inline: child.inline,
})
}
}
if (!options.description && !options.author) {
options.description = "_ _"
}
return options
}
type ActionRowOptions = Required<BaseMessageComponentOptions> &
MessageActionRowOptions
function addActionRowItems(components: ActionRowOptions[], nodes: Node[]) {
let actionRow = last(components)
if (
actionRow == undefined ||
actionRow.components[0]?.type === "SELECT_MENU" ||
actionRow.components.length >= 5
) {
actionRow = {
type: "ACTION_ROW",
components: [],
}
components.push(actionRow)
}
for (const node of nodes) {
if (node.type === "button") {
actionRow.components.push({
type: "BUTTON",
label: node.children.map(getNodeText).join(""),
style: node.style ? toUpper(node.style) : "SECONDARY",
emoji: node.emoji,
disabled: node.disabled,
customId: node.customId,
})
}
}
}

11
src/element.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { ReactNode } from "react"
import React from "react"
import type { Node } from "./node.js"
export function ReacordElement<Props>(props: {
props: Props
createNode: () => Node<Props>
children?: ReactNode
}) {
return React.createElement("reacord-element", props)
}

6
src/embed/embed-child.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { MessageEmbedOptions } from "discord.js"
import { Node } from "../node.js"
export abstract class EmbedChildNode<Props> extends Node<Props> {
abstract modifyEmbedOptions(options: MessageEmbedOptions): void
}

30
src/embed/embed-field.tsx Normal file
View File

@@ -0,0 +1,30 @@
import type { MessageEmbedOptions } from "discord.js"
import React from "react"
import { ReacordElement } from "../element.js"
import { EmbedChildNode } from "./embed-child.js"
export type EmbedFieldProps = {
name: string
inline?: boolean
children: string
}
export function EmbedField(props: EmbedFieldProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedFieldNode(props)}
/>
)
}
class EmbedFieldNode extends EmbedChildNode<EmbedFieldProps> {
override modifyEmbedOptions(options: MessageEmbedOptions): void {
options.fields ??= []
options.fields.push({
name: this.props.name,
value: this.props.children,
inline: this.props.inline,
})
}
}

25
src/embed/embed-title.tsx Normal file
View File

@@ -0,0 +1,25 @@
import type { MessageEmbedOptions } from "discord.js"
import React from "react"
import { ReacordElement } from "../element.js"
import { EmbedChildNode } from "./embed-child.js"
export type EmbedTitleProps = {
children: string
url?: string
}
export function EmbedTitle(props: EmbedTitleProps) {
return (
<ReacordElement
props={props}
createNode={() => new EmbedTitleNode(props)}
/>
)
}
class EmbedTitleNode extends EmbedChildNode<EmbedTitleProps> {
override modifyEmbedOptions(options: MessageEmbedOptions): void {
options.title = this.props.children
options.url = this.props.url
}
}

50
src/embed/embed.tsx Normal file
View File

@@ -0,0 +1,50 @@
import type { MessageOptions } from "discord.js"
import React from "react"
import { ReacordElement } from "../element.js"
import { Node } from "../node.js"
import { EmbedChildNode } from "./embed-child.js"
export type EmbedProps = {
description?: string
url?: string
timestamp?: Date
color?: number
footer?: {
text: string
iconURL?: string
}
image?: {
url: string
}
thumbnail?: {
url: string
}
author?: {
name: string
url?: string
iconURL?: string
}
children?: React.ReactNode
}
export function Embed(props: EmbedProps) {
return (
<ReacordElement props={props} createNode={() => new EmbedNode(props)}>
{props.children}
</ReacordElement>
)
}
class EmbedNode extends Node<EmbedProps> {
override modifyMessageOptions(options: MessageOptions): void {
const embed = { ...this.props }
for (const child of this.children) {
if (child instanceof EmbedChildNode) {
child.modifyEmbedOptions(embed)
}
}
options.embeds ??= []
options.embeds.push(embed)
}
}

View File

@@ -0,0 +1,5 @@
import { raise } from "./raise.js"
export function getEnvironmentValue(name: string) {
return process.env[name] ?? raise(`Missing environment variable: ${name}`)
}

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line import/no-unused-modules
export function omit<Subject extends object, Key extends keyof Subject>(
subject: Subject,
...keys: Key[]

11
src/jsx.d.ts vendored
View File

@@ -1,11 +0,0 @@
declare global {
// namespace JSX {
// // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
// interface IntrinsicElements {
// "reacord-element": {
// createNode: () => Node
// children?: ReactNode
// }
// }
// }
}

View File

@@ -1,7 +1,5 @@
/* eslint-disable import/no-unused-modules */
export * from "./components/action-row.jsx"
export * from "./components/button.jsx"
export * from "./components/embed-field.jsx"
export * from "./components/embed.jsx"
export * from "./components/text.jsx"
export * from "./root.js"
export * from "./button"
export * from "./embed/embed"
export * from "./embed/embed-field"
export * from "./embed/embed-title"
export * from "./reacord"

View File

@@ -1,72 +1,24 @@
import type {
ButtonInteraction,
ColorResolvable,
EmojiResolvable,
} from "discord.js"
import type { ButtonStyle } from "./components/button.jsx"
/* eslint-disable class-methods-use-this */
import type { MessageComponentInteraction, MessageOptions } from "discord.js"
import { Container } from "./container.js"
export type MessageNode = {
type: "message"
children: Node[]
}
export abstract class Node<Props> {
readonly children = new Container<Node<unknown>>()
protected props: Props
export type TextNode = {
type: "text"
text: string
}
type TextElementNode = {
type: "textElement"
children: Node[]
}
export type EmbedNode = {
type: "embed"
title?: string
color?: ColorResolvable
url?: string
timestamp?: Date | number | string
imageUrl?: string
thumbnailUrl?: string
author?: {
name: string
url?: string
iconUrl?: string
constructor(initialProps: Props) {
this.props = initialProps
}
footer?: {
text: string
iconUrl?: string
setProps(props: Props) {
this.props = props
}
children: Node[]
}
type EmbedFieldNode = {
type: "embedField"
name: string
inline?: boolean
children: Node[]
}
modifyMessageOptions(options: MessageOptions) {}
type ActionRowNode = {
type: "actionRow"
children: Node[]
handleInteraction(
interaction: MessageComponentInteraction,
): true | undefined {
return undefined
}
}
type ButtonNode = {
type: "button"
style?: ButtonStyle
emoji?: EmojiResolvable
disabled?: boolean
customId: string
onClick: (interaction: ButtonInteraction) => void
children: Node[]
}
export type Node =
| MessageNode
| TextNode
| TextElementNode
| EmbedNode
| EmbedFieldNode
| ActionRowNode
| ButtonNode

5
src/reacord.test.tsx Normal file
View File

@@ -0,0 +1,5 @@
import "dotenv/config.js"
import { getEnvironmentValue } from "./helpers/get-environment-value.js"
const testBotToken = getEnvironmentValue("TEST_BOT_TOKEN")
const testChannelId = getEnvironmentValue("TEST_CHANNEL_ID")

70
src/reacord.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { Client, CommandInteraction } from "discord.js"
import type { ReactNode } from "react"
import { reconciler } from "./reconciler.js"
import { Renderer } from "./renderer.js"
export type ReacordConfig = {
/**
* A Discord.js client. Reacord will listen to interaction events
* and send them to active instances. */
client: Client
/**
* The max number of active instances.
* When this limit is exceeded, the oldest instances will be disabled.
*/
maxInstances?: number
}
export type ReacordInstance = {
render: (content: ReactNode) => void
deactivate: () => void
}
export class Reacord {
private renderers: Renderer[] = []
private constructor(private readonly config: ReacordConfig) {}
private get maxInstances() {
return this.config.maxInstances ?? 50
}
static create(config: ReacordConfig) {
const manager = new Reacord(config)
config.client.on("interactionCreate", (interaction) => {
if (!interaction.isMessageComponent()) return
for (const renderer of manager.renderers) {
if (renderer.handleInteraction(interaction)) return
}
})
return manager
}
reply(interaction: CommandInteraction): ReacordInstance {
if (this.renderers.length > this.maxInstances) {
this.deactivate(this.renderers[0]!)
}
const renderer = new Renderer(interaction)
this.renderers.push(renderer)
const container = reconciler.createContainer(renderer, 0, false, {})
return {
render: (content: ReactNode) => {
reconciler.updateContainer(content, container)
},
deactivate: () => {
this.deactivate(renderer)
},
}
}
private deactivate(renderer: Renderer) {
this.renderers = this.renderers.filter((it) => it !== renderer)
renderer.deactivate()
}
}

View File

@@ -1,114 +1,102 @@
/* eslint-disable unicorn/no-null */
import { inspect } from "node:util"
import type { HostConfig } from "react-reconciler"
import ReactReconciler from "react-reconciler"
import type { ChannelRenderer } from "./channel-renderer.js"
import { raise } from "./helpers/raise.js"
import type { MessageNode, Node, TextNode } from "./node.js"
import { Node } from "./node.js"
import type { Renderer } from "./renderer.js"
import { TextNode } from "./text.js"
type ElementTag = string
type Props = Record<string, unknown>
const createInstance = (type: string, props: Props): Node => {
if (type !== "reacord-element") {
raise(`createInstance: unknown type: ${type}`)
}
if (typeof props.createNode !== "function") {
const actual = inspect(props.createNode)
raise(`invalid createNode function, received: ${actual}`)
}
return props.createNode()
}
type ChildSet = MessageNode
export const reconciler = ReactReconciler<
string, // Type (jsx tag),
Props, // Props,
ChannelRenderer, // Container,
Node, // Instance,
const config: HostConfig<
string, // Type,
Record<string, unknown>, // Props,
Renderer, // Container,
Node<unknown>, // Instance,
TextNode, // TextInstance,
never, // SuspenseInstance,
never, // HydratableInstance,
never, // PublicInstance,
null, // HostContext,
[], // UpdatePayload,
ChildSet, // ChildSet,
unknown, // TimeoutHandle,
unknown // NoTimeout
>({
never, // HostContext,
true, // UpdatePayload,
never, // ChildSet,
number, // TimeoutHandle,
number // NoTimeout,
> = {
// config
now: Date.now,
isPrimaryRenderer: true,
supportsMutation: false,
supportsPersistence: true,
supportsMutation: true,
supportsPersistence: false,
supportsHydration: false,
scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
isPrimaryRenderer: true,
scheduleTimeout: global.setTimeout,
cancelTimeout: global.clearTimeout,
noTimeout: -1,
// eslint-disable-next-line unicorn/no-null
getRootHostContext: () => null,
getChildHostContext: (parentContext) => parentContext,
createInstance: (type, props) => {
if (type !== "reacord-element") {
raise(`Unknown element type: ${type}`)
}
if (typeof props.createNode !== "function") {
raise(`Missing createNode function`)
}
const node = props.createNode(props.props)
if (!(node instanceof Node)) {
raise(`createNode function did not return a Node`)
}
return node
},
createTextInstance: (text) => new TextNode(text),
shouldSetTextContent: () => false,
createInstance,
createTextInstance: (text) => ({ type: "text", text }),
createContainerChildSet: (): ChildSet => ({
type: "message",
children: [],
}),
appendChildToContainerChildSet: (childSet: ChildSet, child: Node) => {
childSet.children.push(child)
clearContainer: (renderer) => {
renderer.nodes.clear()
},
finalizeContainerChildren: (container: ChannelRenderer, children: ChildSet) =>
false,
replaceContainerChildren: (
container: ChannelRenderer,
children: ChildSet,
) => {
container.render(children)
appendChildToContainer: (renderer, child) => {
renderer.nodes.add(child)
},
removeChildFromContainer: (renderer, child) => {
renderer.nodes.remove(child)
},
insertInContainerBefore: (renderer, child, before) => {
renderer.nodes.addBefore(child, before)
},
appendInitialChild: (parent, child) => {
if ("children" in parent) {
parent.children.push(child)
} else {
raise(`${parent.type} cannot have children`)
}
parent.children.add(child)
},
appendChild: (parent, child) => {
parent.children.add(child)
},
removeChild: (parent, child) => {
parent.children.remove(child)
},
insertBefore: (parent, child, before) => {
parent.children.addBefore(child, before)
},
cloneInstance: (
instance: Node,
_: unknown,
type: ElementTag,
oldProps: Props,
newProps: Props,
) => {
const newInstance = createInstance(type, newProps)
// instance children don't get carried over, so we need to copy them
if ("children" in instance && "children" in newInstance) {
newInstance.children = instance.children
}
return newInstance
prepareUpdate: () => true,
commitUpdate: (node, payload, type, oldProps, newProps) => {
node.setProps(newProps.props)
},
commitTextUpdate: (node, oldText, newText) => {
node.setProps(newText)
},
// returning a non-null value tells react to re-render the whole thing
// on any prop change
//
// we can probably optimize this to actually compare old/new props though
prepareUpdate: () => [],
// eslint-disable-next-line unicorn/no-null
prepareForCommit: () => null,
resetAfterCommit: (renderer) => {
renderer.render()
},
preparePortalMount: () => raise("Portals are not supported"),
getPublicInstance: () => raise("Refs are currently not supported"),
finalizeInitialChildren: () => false,
prepareForCommit: (container) => null,
resetAfterCommit: () => null,
getPublicInstance: () => raise("Not implemented"),
preparePortalMount: () => raise("Not implemented"),
})
}
export const reconciler = ReactReconciler(config)

126
src/renderer.ts Normal file
View File

@@ -0,0 +1,126 @@
import type {
CommandInteraction,
MessageComponentInteraction,
MessageOptions,
} from "discord.js"
import type { Subscription } from "rxjs"
import { Subject } from "rxjs"
import { concatMap } from "rxjs/operators"
import { Container } from "./container.js"
import type { Node } from "./node.js"
// keep track of interaction ids which have replies,
// so we know whether to call reply() or followUp()
const repliedInteractionIds = new Set<string>()
type UpdatePayload = {
options: MessageOptions
action: "update" | "deactivate"
}
export class Renderer {
readonly nodes = new Container<Node<unknown>>()
private componentInteraction?: MessageComponentInteraction
private messageId?: string
private updates = new Subject<UpdatePayload>()
private updateSubscription: Subscription
private active = true
constructor(private interaction: CommandInteraction) {
this.updateSubscription = this.updates
.pipe(concatMap((payload) => this.updateMessage(payload)))
.subscribe()
}
render() {
if (!this.active) {
console.warn("Attempted to update a deactivated message")
return
}
this.updates.next({
options: this.getMessageOptions(),
action: "update",
})
}
deactivate() {
this.active = false
this.updates.next({
options: this.getMessageOptions(),
action: "deactivate",
})
}
handleInteraction(interaction: MessageComponentInteraction) {
for (const node of this.nodes) {
this.componentInteraction = interaction
if (node.handleInteraction(interaction)) {
return true
}
}
}
private getMessageOptions(): MessageOptions {
const options: MessageOptions = {
content: "",
embeds: [],
components: [],
}
for (const node of this.nodes) {
node.modifyMessageOptions(options)
}
return options
}
private async updateMessage({ options, action }: UpdatePayload) {
if (action === "deactivate" && this.messageId) {
this.updateSubscription.unsubscribe()
const message = await this.interaction.channel?.messages.fetch(
this.messageId,
)
if (!message) return
for (const actionRow of message.components) {
for (const component of actionRow.components) {
component.setDisabled(true)
}
}
await this.interaction.channel?.messages.edit(message.id, {
components: message.components,
})
return
}
if (this.componentInteraction) {
const promise = this.componentInteraction.update(options)
this.componentInteraction = undefined
await promise
return
}
if (this.messageId) {
await this.interaction.channel?.messages.edit(this.messageId, options)
return
}
if (repliedInteractionIds.has(this.interaction.id)) {
const message = await this.interaction.followUp({
...options,
fetchReply: true,
})
this.messageId = message.id
return
}
repliedInteractionIds.add(this.interaction.id)
const message = await this.interaction.reply({
...options,
fetchReply: true,
})
this.messageId = message.id
}
}

View File

@@ -1,24 +0,0 @@
/* eslint-disable unicorn/no-null */
import type { TextBasedChannels } from "discord.js"
import type { ReactNode } from "react"
import { ChannelRenderer } from "./channel-renderer.js"
import { reconciler } from "./reconciler.js"
export type ReacordRoot = ReturnType<typeof createRoot>
export function createRoot(target: TextBasedChannels) {
const renderer = new ChannelRenderer(target)
const containerId = reconciler.createContainer(renderer, 0, false, null)
return {
render: (content: ReactNode) => {
reconciler.updateContainer(content, containerId)
return renderer.done()
},
destroy: () => {
reconciler.updateContainer(null, containerId)
renderer.destroy()
return renderer.done()
},
done: () => renderer.done(),
}
}

8
src/text.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { MessageOptions } from "discord.js"
import { Node } from "./node.js"
export class TextNode extends Node<string> {
override modifyMessageOptions(options: MessageOptions) {
options.content = (options.content ?? "") + this.props
}
}