remove old sources
This commit is contained in:
@@ -1,413 +0,0 @@
|
||||
/* eslint-disable unicorn/no-null */
|
||||
import type { ButtonInteraction, Message, MessageOptions } from "discord.js"
|
||||
import { Client, TextChannel } from "discord.js"
|
||||
import { nanoid } from "nanoid"
|
||||
import React, { useState } from "react"
|
||||
import { afterAll, beforeAll, expect, test } from "vitest"
|
||||
import { omit } from "../src/helpers/omit.js"
|
||||
import { raise } from "../src/helpers/raise.js"
|
||||
import { waitForWithTimeout } from "../src/helpers/wait-for-with-timeout.js"
|
||||
import type { ReacordRoot } from "../src/main.js"
|
||||
import {
|
||||
ActionRow,
|
||||
Button,
|
||||
createRoot,
|
||||
Embed,
|
||||
EmbedField,
|
||||
Text,
|
||||
} from "../src/main.js"
|
||||
import { testBotToken, testChannelId } from "./test-environment.js"
|
||||
|
||||
const client = new Client({
|
||||
intents: ["GUILDS"],
|
||||
})
|
||||
|
||||
let channel: TextChannel
|
||||
let root: ReacordRoot
|
||||
|
||||
beforeAll(async () => {
|
||||
await client.login(testBotToken)
|
||||
|
||||
const result =
|
||||
client.channels.cache.get(testChannelId) ??
|
||||
(await client.channels.fetch(testChannelId)) ??
|
||||
raise("Channel not found")
|
||||
|
||||
if (!(result instanceof TextChannel)) {
|
||||
throw new TypeError("Channel must be a text channel")
|
||||
}
|
||||
|
||||
channel = result
|
||||
|
||||
for (const [, message] of await channel.messages.fetch()) {
|
||||
await message.delete()
|
||||
}
|
||||
|
||||
root = createRoot(channel)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
client.destroy()
|
||||
})
|
||||
|
||||
test("rapid updates", async () => {
|
||||
// rapid updates
|
||||
void root.render("hi world")
|
||||
void root.render("hi the")
|
||||
await root.render("hi moon")
|
||||
await assertMessages([{ content: "hi moon" }])
|
||||
})
|
||||
|
||||
test("nested text", async () => {
|
||||
await root.render(
|
||||
<Text>
|
||||
<Text>hi world</Text>{" "}
|
||||
<Text>
|
||||
hi moon <Text>hi sun</Text>
|
||||
</Text>
|
||||
</Text>,
|
||||
)
|
||||
await assertMessages([{ content: "hi world hi moon hi sun" }])
|
||||
})
|
||||
|
||||
test("empty embed fallback", async () => {
|
||||
await root.render(<Embed />)
|
||||
await assertMessages([{ embeds: [{ description: "_ _" }] }])
|
||||
})
|
||||
|
||||
test("embed with only author", async () => {
|
||||
await root.render(<Embed author={{ name: "only author" }} />)
|
||||
await assertMessages([{ embeds: [{ author: { name: "only author" } }] }])
|
||||
})
|
||||
|
||||
test("kitchen sink", async () => {
|
||||
const timestamp = Date.now()
|
||||
const image =
|
||||
"https://cdn.discordapp.com/avatars/109677308410875904/3e53fcb70760a08fa63f73376ede5d1f.png?size=1024"
|
||||
|
||||
await root.render(
|
||||
<>
|
||||
message <Text>content</Text>
|
||||
no space
|
||||
<Embed
|
||||
color="#feeeef"
|
||||
title="the embed"
|
||||
url="https://example.com"
|
||||
timestamp={timestamp}
|
||||
imageUrl={image}
|
||||
thumbnailUrl={image}
|
||||
author={{
|
||||
name: "hi craw",
|
||||
url: "https://example.com",
|
||||
iconUrl: image,
|
||||
}}
|
||||
footer={{
|
||||
text: "the footer",
|
||||
iconUrl: image,
|
||||
}}
|
||||
>
|
||||
description <Text>more description</Text>
|
||||
</Embed>
|
||||
<Embed>
|
||||
another <Text>hi</Text>
|
||||
<EmbedField name="field name">field content</EmbedField>
|
||||
<EmbedField name="field name" inline>
|
||||
field content but inline
|
||||
</EmbedField>
|
||||
</Embed>
|
||||
<Button onClick={() => {}} style="primary">
|
||||
primary button
|
||||
</Button>
|
||||
<Button onClick={() => {}} style="danger">
|
||||
danger button
|
||||
</Button>
|
||||
<Button onClick={() => {}} style="success">
|
||||
success button
|
||||
</Button>
|
||||
<Button onClick={() => {}} style="secondary">
|
||||
secondary button
|
||||
</Button>
|
||||
<Button onClick={() => {}}>secondary by default</Button>
|
||||
<Button onClick={() => {}}>
|
||||
complex <Text>button</Text> text
|
||||
</Button>
|
||||
<Button onClick={() => {}} disabled>
|
||||
disabled button
|
||||
</Button>
|
||||
<ActionRow>
|
||||
<Button onClick={() => {}}>new action row</Button>
|
||||
</ActionRow>
|
||||
</>,
|
||||
)
|
||||
await assertMessages([
|
||||
{
|
||||
content: "message contentno space",
|
||||
embeds: [
|
||||
{
|
||||
color: 0xfe_ee_ef,
|
||||
description: "description more description",
|
||||
image: { url: image },
|
||||
thumbnail: { url: image },
|
||||
author: {
|
||||
name: "hi craw",
|
||||
url: "https://example.com",
|
||||
iconURL:
|
||||
"https://cdn.discordapp.com/avatars/109677308410875904/3e53fcb70760a08fa63f73376ede5d1f.png?size=1024",
|
||||
},
|
||||
footer: {
|
||||
text: "the footer",
|
||||
iconURL:
|
||||
"https://cdn.discordapp.com/avatars/109677308410875904/3e53fcb70760a08fa63f73376ede5d1f.png?size=1024",
|
||||
},
|
||||
timestamp,
|
||||
title: "the embed",
|
||||
url: "https://example.com",
|
||||
},
|
||||
{
|
||||
description: "another hi",
|
||||
fields: [
|
||||
{ name: "field name", value: "field content", inline: false },
|
||||
{
|
||||
name: "field name",
|
||||
value: "field content but inline",
|
||||
inline: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
components: [
|
||||
{
|
||||
type: "ACTION_ROW",
|
||||
components: [
|
||||
{
|
||||
type: "BUTTON",
|
||||
label: "primary button",
|
||||
style: "PRIMARY",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
type: "BUTTON",
|
||||
label: "danger button",
|
||||
style: "DANGER",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
type: "BUTTON",
|
||||
label: "success button",
|
||||
style: "SUCCESS",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
type: "BUTTON",
|
||||
label: "secondary button",
|
||||
style: "SECONDARY",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
type: "BUTTON",
|
||||
label: "secondary by default",
|
||||
style: "SECONDARY",
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "ACTION_ROW",
|
||||
components: [
|
||||
{
|
||||
type: "BUTTON",
|
||||
label: "complex button text",
|
||||
style: "SECONDARY",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
type: "BUTTON",
|
||||
label: "disabled button",
|
||||
style: "SECONDARY",
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "ACTION_ROW",
|
||||
components: [
|
||||
{
|
||||
type: "BUTTON",
|
||||
label: "new action row",
|
||||
style: "SECONDARY",
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("button onClick", async () => {
|
||||
let clicked = false
|
||||
await root.render(<Button onClick={() => (clicked = true)} emoji="➕" />)
|
||||
await clickButton()
|
||||
await waitForWithTimeout(() => clicked, 1000)
|
||||
})
|
||||
|
||||
test("button click with state", async () => {
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0)
|
||||
return (
|
||||
<>
|
||||
the count is {count}
|
||||
<Button onClick={() => setCount(count + 1)}>increment</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
async function assertCount(count: number) {
|
||||
await assertMessages([
|
||||
{
|
||||
content: `the count is ${count}`,
|
||||
components: [
|
||||
{
|
||||
type: "ACTION_ROW",
|
||||
components: [
|
||||
{
|
||||
type: "BUTTON",
|
||||
style: "SECONDARY",
|
||||
label: "increment",
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
await root.render(<Counter />)
|
||||
await assertCount(0)
|
||||
await clickButton()
|
||||
await assertCount(1)
|
||||
await clickButton()
|
||||
await assertCount(2)
|
||||
}, 10_000)
|
||||
|
||||
test("destroy", async () => {
|
||||
await root.destroy()
|
||||
await assertMessages([])
|
||||
})
|
||||
|
||||
async function assertMessages(expected: MessageOptions[]) {
|
||||
const messages = await channel.messages.fetch()
|
||||
|
||||
expect(messages.map((message) => extractMessageData(message))).toEqual(
|
||||
expected,
|
||||
)
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
async function clickButton(index = 0) {
|
||||
const messages = await channel.messages.fetch()
|
||||
|
||||
const components = [...messages.values()]
|
||||
.flatMap((message) => message.components.flatMap((row) => row.components))
|
||||
.flatMap((component) => (component.type === "BUTTON" ? [component] : []))
|
||||
|
||||
const customId =
|
||||
components[index]?.customId ?? raise(`Button not found at index ${index}`)
|
||||
|
||||
global.setTimeout(() => {
|
||||
channel.client.emit("interactionCreate", createButtonInteraction(customId))
|
||||
})
|
||||
await channel.awaitMessageComponent({
|
||||
filter: (interaction) => interaction.customId === customId,
|
||||
time: 1000,
|
||||
})
|
||||
await root.done()
|
||||
}
|
||||
|
||||
function createButtonInteraction(customId: string) {
|
||||
return {
|
||||
id: nanoid(),
|
||||
type: "MESSAGE_COMPONENT",
|
||||
componentType: "BUTTON",
|
||||
channelId: channel.id,
|
||||
guildId: channel.guildId,
|
||||
isButton: () => true,
|
||||
customId,
|
||||
user: { id: "123" },
|
||||
deferUpdate: () => Promise.resolve(),
|
||||
} as ButtonInteraction
|
||||
}
|
||||
|
||||
function extractMessageData(message: Message): MessageOptions {
|
||||
return pruneUndefinedValues({
|
||||
content: nonEmptyOrUndefined(message.content),
|
||||
embeds: nonEmptyOrUndefined(
|
||||
pruneUndefinedValues(
|
||||
message.embeds.map((embed) => ({
|
||||
title: embed.title ?? undefined,
|
||||
description: embed.description ?? undefined,
|
||||
url: embed.url ?? undefined,
|
||||
timestamp: embed.timestamp ?? undefined,
|
||||
color: embed.color ?? undefined,
|
||||
fields: nonEmptyOrUndefined(embed.fields),
|
||||
author: embed.author ? omit(embed.author, "proxyIconURL") : undefined,
|
||||
thumbnail: embed.thumbnail
|
||||
? omit(embed.thumbnail, "proxyURL", "width", "height")
|
||||
: undefined,
|
||||
image: embed.image
|
||||
? omit(embed.image, "proxyURL", "width", "height")
|
||||
: undefined,
|
||||
video: embed.video ?? undefined,
|
||||
footer: embed.footer ? omit(embed.footer, "proxyIconURL") : undefined,
|
||||
})),
|
||||
),
|
||||
),
|
||||
components: nonEmptyOrUndefined(
|
||||
message.components.map((row) => ({
|
||||
type: "ACTION_ROW",
|
||||
components: row.components.map((component) => {
|
||||
if (component.type === "BUTTON") {
|
||||
return pruneUndefinedValues({
|
||||
type: "BUTTON",
|
||||
style: component.style ?? "SECONDARY",
|
||||
label: component.label ?? undefined,
|
||||
emoji: component.emoji?.name,
|
||||
url: component.url ?? undefined,
|
||||
disabled: component.disabled ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (component.type === "SELECT_MENU") {
|
||||
return pruneUndefinedValues({
|
||||
type: "SELECT_MENU",
|
||||
disabled: component.disabled ?? undefined,
|
||||
options: component.options.map((option) => ({
|
||||
label: option.label ?? undefined,
|
||||
value: option.value ?? undefined,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
raise(`unknown component type ${(component as any).type}`)
|
||||
}),
|
||||
})),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function pruneUndefinedValues<T>(input: T) {
|
||||
return JSON.parse(JSON.stringify(input))
|
||||
}
|
||||
|
||||
function nonEmptyOrUndefined<T extends unknown>(input: T): T | undefined {
|
||||
if (
|
||||
input == undefined ||
|
||||
input === "" ||
|
||||
(Array.isArray(input) && input.length === 0)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return input
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import "dotenv/config.js"
|
||||
import { raise } from "../src/helpers/raise.js"
|
||||
|
||||
function getEnvironmentValue(name: string) {
|
||||
return process.env[name] ?? raise(`Missing environment variable: ${name}`)
|
||||
}
|
||||
|
||||
export const testBotToken = getEnvironmentValue("TEST_BOT_TOKEN")
|
||||
// export const testGuildId = getEnvironmentValue("TEST_GUILD_ID")
|
||||
export const testChannelId = getEnvironmentValue("TEST_CHANNEL_ID")
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { Button, Embed, EmbedField, EmbedTitle } from "../src.new/main"
|
||||
import { Button, Embed, EmbedField, EmbedTitle } from "../src/main"
|
||||
|
||||
export function Counter(props: { onDeactivate: () => void }) {
|
||||
const [count, setCount] = React.useState(0)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Client } from "discord.js"
|
||||
import "dotenv/config"
|
||||
import React from "react"
|
||||
import { Reacord } from "../src.new/main.js"
|
||||
import { Reacord } from "../src/main.js"
|
||||
import { createCommandHandler } from "./command-handler.js"
|
||||
import { Counter } from "./counter.js"
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from "./button"
|
||||
export * from "./embed/embed"
|
||||
export * from "./embed/embed-field"
|
||||
export * from "./embed/embed-title"
|
||||
export * from "./reacord"
|
||||
@@ -1,24 +0,0 @@
|
||||
/* eslint-disable class-methods-use-this */
|
||||
import type { MessageComponentInteraction, MessageOptions } from "discord.js"
|
||||
import { Container } from "./container.js"
|
||||
|
||||
export abstract class Node<Props> {
|
||||
readonly children = new Container<Node<unknown>>()
|
||||
protected props: Props
|
||||
|
||||
constructor(initialProps: Props) {
|
||||
this.props = initialProps
|
||||
}
|
||||
|
||||
setProps(props: Props) {
|
||||
this.props = props
|
||||
}
|
||||
|
||||
modifyMessageOptions(options: MessageOptions) {}
|
||||
|
||||
handleInteraction(
|
||||
interaction: MessageComponentInteraction,
|
||||
): true | undefined {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { HostConfig } from "react-reconciler"
|
||||
import ReactReconciler from "react-reconciler"
|
||||
import { raise } from "../src/helpers/raise.js"
|
||||
import { Node } from "./node.js"
|
||||
import type { Renderer } from "./renderer.js"
|
||||
import { TextNode } from "./text.js"
|
||||
|
||||
const config: HostConfig<
|
||||
string, // Type,
|
||||
Record<string, unknown>, // Props,
|
||||
Renderer, // Container,
|
||||
Node<unknown>, // Instance,
|
||||
TextNode, // TextInstance,
|
||||
never, // SuspenseInstance,
|
||||
never, // HydratableInstance,
|
||||
never, // PublicInstance,
|
||||
never, // HostContext,
|
||||
true, // UpdatePayload,
|
||||
never, // ChildSet,
|
||||
number, // TimeoutHandle,
|
||||
number // NoTimeout,
|
||||
> = {
|
||||
// config
|
||||
now: Date.now,
|
||||
supportsMutation: true,
|
||||
supportsPersistence: false,
|
||||
supportsHydration: false,
|
||||
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,
|
||||
|
||||
clearContainer: (renderer) => {
|
||||
renderer.nodes.clear()
|
||||
},
|
||||
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) => {
|
||||
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)
|
||||
},
|
||||
|
||||
prepareUpdate: () => true,
|
||||
commitUpdate: (node, payload, type, oldProps, newProps) => {
|
||||
node.setProps(newProps.props)
|
||||
},
|
||||
commitTextUpdate: (node, oldText, newText) => {
|
||||
node.setProps(newText)
|
||||
},
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
export const reconciler = ReactReconciler(config)
|
||||
@@ -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"])
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import type {
|
||||
import { MessageActionRow } from "discord.js"
|
||||
import { nanoid } from "nanoid"
|
||||
import React from "react"
|
||||
import { last } from "../src/helpers/last.js"
|
||||
import { toUpper } from "../src/helpers/to-upper.js"
|
||||
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 = {
|
||||
@@ -23,7 +24,7 @@ export type ButtonProps = {
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
return (
|
||||
<reacord-element props={props} createNode={() => new ButtonNode(props)} />
|
||||
<ReacordElement props={props} createNode={() => new ButtonNode(props)} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/helpers/get-environment-value.ts
Normal file
5
src/helpers/get-environment-value.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { raise } from "./raise.js"
|
||||
|
||||
export function getEnvironmentValue(name: string) {
|
||||
return process.env[name] ?? raise(`Missing environment variable: ${name}`)
|
||||
}
|
||||
@@ -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
11
src/jsx.d.ts
vendored
@@ -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
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
12
src/main.ts
12
src/main.ts
@@ -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"
|
||||
|
||||
80
src/node.ts
80
src/node.ts
@@ -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
|
||||
|
||||
constructor(initialProps: Props) {
|
||||
this.props = initialProps
|
||||
}
|
||||
|
||||
export type TextNode = {
|
||||
type: "text"
|
||||
text: string
|
||||
setProps(props: Props) {
|
||||
this.props = props
|
||||
}
|
||||
|
||||
type TextElementNode = {
|
||||
type: "textElement"
|
||||
children: Node[]
|
||||
}
|
||||
modifyMessageOptions(options: MessageOptions) {}
|
||||
|
||||
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
|
||||
handleInteraction(
|
||||
interaction: MessageComponentInteraction,
|
||||
): true | undefined {
|
||||
return undefined
|
||||
}
|
||||
footer?: {
|
||||
text: string
|
||||
iconUrl?: string
|
||||
}
|
||||
children: Node[]
|
||||
}
|
||||
|
||||
type EmbedFieldNode = {
|
||||
type: "embedField"
|
||||
name: string
|
||||
inline?: boolean
|
||||
children: Node[]
|
||||
}
|
||||
|
||||
type ActionRowNode = {
|
||||
type: "actionRow"
|
||||
children: Node[]
|
||||
}
|
||||
|
||||
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
5
src/reacord.test.tsx
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
24
src/root.ts
24
src/root.ts
@@ -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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user