diff --git a/packages/integration-tests/tests/rendering.test.tsx b/packages/integration-tests/tests/rendering.test.tsx index 1104e37..63be35a 100644 --- a/packages/integration-tests/tests/rendering.test.tsx +++ b/packages/integration-tests/tests/rendering.test.tsx @@ -1,8 +1,10 @@ +/* eslint-disable unicorn/numeric-separators-style */ +/* eslint-disable unicorn/no-null */ import type { ExecutionContext } from "ava" import test from "ava" import type { Message } from "discord.js" import { Client, TextChannel } from "discord.js" -import { createRoot, Text } from "reacord" +import { createRoot, Embed, Text } from "reacord" import { pick } from "reacord-helpers/pick.js" import { raise } from "reacord-helpers/raise.js" import React from "react" @@ -38,35 +40,55 @@ test.beforeEach(async () => { await Promise.all(messages.map((message) => message.delete())) }) -test.serial("basic text & content updates", async (t) => { +test.serial("kitchen sink + destroy", async (t) => { const root = createRoot(channel) - await root.render("hi world") - await assertMessages(t, [{ content: "hi world" }]) - await root.render( <> - {"hi world"} {"hi moon"} + message content + no space + + description more description + + + another hi + , ) - await assertMessages(t, [{ content: "hi world hi moon" }]) - - await root.render(hi world) - await assertMessages(t, [{ content: "hi world" }]) - - await root.render() - await assertMessages(t, [{ content: "_ _" }]) - - await root.render(hi world) - await assertMessages(t, [{ content: "hi world" }]) - - await root.render([]) - await assertMessages(t, [{ content: "_ _" }]) + await assertMessages(t, [ + { + content: "message contentno space", + embeds: [ + { + color: 0xfeeeef, + description: "description more description", + }, + { color: null, description: "another hi" }, + ], + }, + ]) await root.destroy() await assertMessages(t, []) }) +test.serial("updates", async (t) => { + const root = createRoot(channel) + + // rapid updates + void root.render("hi world") + await root.render("hi moon") + await assertMessages(t, [{ content: "hi moon" }]) + + // regular update after initial render + await root.render(hi sun) + await assertMessages(t, [{ content: "hi sun" }]) + + // update that requires cloning a node + await root.render(the) + await assertMessages(t, [{ content: "the" }]) +}) + test.serial("nested text", async (t) => { const root = createRoot(channel) @@ -81,9 +103,31 @@ test.serial("nested text", async (t) => { await assertMessages(t, [{ content: "hi world hi moon hi sun" }]) }) +test.serial("empty embed fallback", async (t) => { + const root = createRoot(channel) + + await root.render() + await assertMessages(t, [{ embeds: [{ color: null, description: "_ _" }] }]) +}) + +test.serial("invalid children error", (t) => { + const root = createRoot(channel) + + t.throws(() => + root.render( + + + , + ), + ) +}) + type MessageData = ReturnType function extractMessageData(message: Message) { - return pick(message, "content", "embeds", "components") + return { + content: message.content, + embeds: message.embeds.map((embed) => pick(embed, "color", "description")), + } } async function assertMessages( @@ -95,7 +139,6 @@ async function assertMessages( const messageDataFallback: MessageData = { content: "", embeds: [], - components: [], } t.deepEqual( diff --git a/packages/reacord/src/components/embed.tsx b/packages/reacord/src/components/embed.tsx new file mode 100644 index 0000000..f581387 --- /dev/null +++ b/packages/reacord/src/components/embed.tsx @@ -0,0 +1,12 @@ +import type { ColorResolvable } from "discord.js" +import type { ReactNode } from "react" +import React from "react" + +export type EmbedProps = { + color?: ColorResolvable + children?: ReactNode +} + +export function Embed(props: EmbedProps) { + return +} diff --git a/packages/reacord/src/elements.ts b/packages/reacord/src/elements.ts index 55dec10..e21d235 100644 --- a/packages/reacord/src/elements.ts +++ b/packages/reacord/src/elements.ts @@ -1,7 +1,9 @@ +import type { EmbedProps } from "./components/embed.js" import type { TextProps } from "./components/text.jsx" export type ReacordElementMap = { "reacord-text": TextProps + "reacord-embed": EmbedProps } declare global { diff --git a/packages/reacord/src/main.ts b/packages/reacord/src/main.ts index c34b051..2786406 100644 --- a/packages/reacord/src/main.ts +++ b/packages/reacord/src/main.ts @@ -1,2 +1,3 @@ +export * from "./components/embed.js" export * from "./components/text.js" export * from "./root.js" diff --git a/packages/reacord/src/reconciler.ts b/packages/reacord/src/reconciler.ts index d4a84c9..99a0a36 100644 --- a/packages/reacord/src/reconciler.ts +++ b/packages/reacord/src/reconciler.ts @@ -3,11 +3,12 @@ import { raise } from "reacord-helpers/raise.js" import ReactReconciler from "react-reconciler" import type { ReacordElementMap } from "./elements.js" import type { ReacordContainer } from "./renderer/container.js" +import { EmbedInstance } from "./renderer/embed-instance.js" import { TextElementInstance } from "./renderer/text-element-instance.js" import { TextInstance } from "./renderer/text-instance.js" // instances that represent an element -type ElementInstance = TextElementInstance +type ElementInstance = TextElementInstance | EmbedInstance // any instance type Instance = ElementInstance | TextInstance @@ -18,13 +19,13 @@ type ElementTag = type Props = Record -const createInstance = ( - type: ElementTag, - props: Props, -): TextElementInstance => { +const createInstance = (type: ElementTag, props: Props): ElementInstance => { if (type === "reacord-text") { return new TextElementInstance() } + if (type === "reacord-embed") { + return new EmbedInstance((props as any).color) + } raise(`Unknown element type "${type}"`) } @@ -79,7 +80,25 @@ export const reconciler = ReactReconciler< }, appendInitialChild: (parent, child) => { - parent.add(child) + if ( + parent instanceof TextElementInstance && + (child instanceof TextInstance || child instanceof TextElementInstance) + ) { + parent.add(child) + return + } + + if ( + parent instanceof EmbedInstance && + (child instanceof TextInstance || child instanceof TextElementInstance) + ) { + parent.add(child) + return + } + + raise( + `Cannot append child of type ${child.constructor.name} to ${parent.constructor.name}`, + ) }, cloneInstance: ( diff --git a/packages/reacord/src/renderer/container.ts b/packages/reacord/src/renderer/container.ts index 4a4b568..70e4378 100644 --- a/packages/reacord/src/renderer/container.ts +++ b/packages/reacord/src/renderer/container.ts @@ -1,4 +1,5 @@ import type { Message, MessageOptions, TextBasedChannels } from "discord.js" +import type { EmbedInstance } from "./embed-instance.js" import type { TextElementInstance } from "./text-element-instance.js" import type { TextInstance } from "./text-instance.js" @@ -6,7 +7,7 @@ type Action = | { type: "updateMessage"; options: MessageOptions } | { type: "deleteMessage" } -type ContainerChild = TextInstance | TextElementInstance +type ContainerChild = TextInstance | TextElementInstance | EmbedInstance export class ReacordContainer { private channel: TextBasedChannels @@ -19,17 +20,17 @@ export class ReacordContainer { } render(children: ContainerChild[]) { - const messageOptions: MessageOptions = {} + const options: MessageOptions = {} for (const child of children) { - child.renderToMessage(messageOptions) + child.renderToMessage(options) } // can't render an empty message - if (!messageOptions?.content) { - messageOptions.content = "_ _" + if (!options?.content && !options.embeds?.length) { + options.content = "_ _" } - this.addAction({ type: "updateMessage", options: messageOptions }) + this.addAction({ type: "updateMessage", options }) } destroy() { diff --git a/packages/reacord/src/renderer/embed-instance.ts b/packages/reacord/src/renderer/embed-instance.ts new file mode 100644 index 0000000..931a45f --- /dev/null +++ b/packages/reacord/src/renderer/embed-instance.ts @@ -0,0 +1,31 @@ +import type { + ColorResolvable, + MessageEmbedOptions, + MessageOptions, +} from "discord.js" +import type { TextElementInstance } from "./text-element-instance.js" +import type { TextInstance } from "./text-instance.js" + +type EmbedChild = TextInstance | TextElementInstance + +export class EmbedInstance { + children: EmbedChild[] = [] + + constructor(readonly color: ColorResolvable) {} + + add(child: EmbedChild) { + this.children.push(child) + } + + renderToMessage(message: MessageOptions) { + message.embeds ??= [] + message.embeds.push(this.embedOptions) + } + + get embedOptions(): MessageEmbedOptions { + return { + color: this.color, + description: this.children.map((child) => child.text).join("") || "_ _", + } + } +}