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,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
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
export * from "./button"
export * from "./embed/embed"
export * from "./embed/embed-field"
export * from "./embed/embed-title"
export * from "./reacord"

View File

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

View File

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

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

View File

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

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

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

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

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)

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