diff --git a/packages/integration-tests/tests/rendering.test.tsx b/packages/integration-tests/tests/rendering.test.tsx index e8098f7..fe4bff4 100644 --- a/packages/integration-tests/tests/rendering.test.tsx +++ b/packages/integration-tests/tests/rendering.test.tsx @@ -4,7 +4,8 @@ import type { ExecutionContext } from "ava" import test from "ava" import type { Message } from "discord.js" import { Client, TextChannel } from "discord.js" -import { createRoot, Embed, Text } from "reacord" +import type { ReacordRoot } from "reacord" +import { createRoot, Embed, EmbedAuthor, Text } from "reacord" import { pick } from "reacord-helpers/pick.js" import { raise } from "reacord-helpers/raise.js" import React from "react" @@ -15,8 +16,9 @@ const client = new Client({ }) let channel: TextChannel +let root: ReacordRoot -test.before(async () => { +test.serial.before(async () => { await client.login(testBotToken) const result = @@ -29,26 +31,80 @@ test.before(async () => { } channel = result + root = createRoot(channel) + + for (const [, message] of await channel.messages.fetch()) { + await message.delete() + } }) test.after(() => { client.destroy() }) -test.beforeEach(async () => { - const messages = await channel.messages.fetch() - await Promise.all(messages.map((message) => message.delete())) +// test.serial.beforeEach(async () => { +// const messages = await channel.messages.fetch() +// await Promise.all(messages.map((message) => message.delete())) +// }) + +test.serial("rapid updates", async (t) => { + // rapid updates + void root.render("hi world") + void root.render("hi the") + await root.render("hi moon") + await assertMessages(t, [{ content: "hi moon" }]) }) -test.serial("kitchen sink + destroy", async (t) => { - const root = createRoot(channel) +test.serial("nested text", async (t) => { + await root.render( + + hi world{" "} + + hi moon hi sun + + , + ) + await assertMessages(t, [{ content: "hi world hi moon hi sun" }]) +}) +test.serial("empty embed fallback", async (t) => { + await root.render() + await assertMessages(t, [{ embeds: [{ description: "_ _" }] }]) +}) + +test.serial("embed with only author", async (t) => { + await root.render( + + only author + , + ) + await assertMessages(t, [ + { embeds: [{ description: "_ _", author: { name: "only author" } }] }, + ]) +}) + +test.serial("empty embed author", async (t) => { + await root.render( + + + , + ) + await assertMessages(t, [{ embeds: [{ description: "_ _" }] }]) +}) + +test.serial("kitchen sink", async (t) => { await root.render( <> message content no space description more description + + hi craw + another hi @@ -62,75 +118,64 @@ test.serial("kitchen sink + destroy", async (t) => { { color: 0xfeeeef, description: "description more description", + author: { + name: "hi craw", + url: "https://example.com", + iconURL: + "https://cdn.discordapp.com/avatars/109677308410875904/3e53fcb70760a08fa63f73376ede5d1f.png?size=1024", + }, }, - { color: null, description: "another hi" }, + { author: {}, color: null, description: "another hi" }, ], }, ]) +}) +test.serial("destroy", async (t) => { 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) - - await root.render( - - hi world{" "} - - hi moon hi sun - - , - ) - 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: "_ _" }] }]) -}) - type MessageData = ReturnType function extractMessageData(message: Message) { return { content: message.content, - embeds: message.embeds.map((embed) => pick(embed, "color", "description")), + embeds: message.embeds.map((embed) => ({ + ...pick(embed, "color", "description"), + author: embed.author + ? pick(embed.author, "name", "url", "iconURL") + : { name: "" }, + })), } } async function assertMessages( t: ExecutionContext, - expected: Array>, + expected: Array>, ) { const messages = await channel.messages.fetch() - const messageDataFallback: MessageData = { - content: "", - embeds: [], - } - t.deepEqual( messages.map((message) => extractMessageData(message)), - expected.map((data) => ({ ...messageDataFallback, ...data })), + expected.map((message) => ({ + content: "", + ...message, + embeds: + message.embeds?.map((embed) => ({ + color: null, + description: "", + ...embed, + author: { + name: "", + ...embed?.author, + }, + })) ?? [], + })), ) } + +type DeepPartial = Subject extends object + ? { + [Key in keyof Subject]?: DeepPartial + } + : Subject diff --git a/packages/reacord/src/base-instance.ts b/packages/reacord/src/base-instance.ts index 041d320..d426db1 100644 --- a/packages/reacord/src/base-instance.ts +++ b/packages/reacord/src/base-instance.ts @@ -1,4 +1,4 @@ -import type { MessageOptions } from "discord.js" +import type { MessageEmbedOptions, MessageOptions } from "discord.js" export abstract class BaseInstance { /** The name of the JSX element represented by this instance */ @@ -10,4 +10,8 @@ export abstract class BaseInstance { /** If this element can be a child of a message, * the function to modify the message options */ renderToMessage?(options: MessageOptions): void + + /** If this element can be a child of an embed, + * the function to modify the embed options */ + renderToEmbed?(options: MessageEmbedOptions): void } diff --git a/packages/reacord/src/embed-author.tsx b/packages/reacord/src/embed-author.tsx new file mode 100644 index 0000000..2d8cbee --- /dev/null +++ b/packages/reacord/src/embed-author.tsx @@ -0,0 +1,35 @@ +import type { MessageEmbedOptions } from "discord.js" +import type { ReactNode } from "react" +import React from "react" +import { ContainerInstance } from "./container-instance.js" + +export type EmbedAuthorProps = { + url?: string + iconUrl?: string + children?: ReactNode +} + +export function EmbedAuthor({ children, ...options }: EmbedAuthorProps) { + return ( + new EmbedAuthorInstance(options)}> + {children} + + ) +} + +type EmbedAuthorOptions = Omit + +class EmbedAuthorInstance extends ContainerInstance { + readonly name = "EmbedAuthor" + + constructor(private readonly props: EmbedAuthorOptions) { + super({ warnOnNonTextChildren: true }) + } + + override renderToEmbed(options: MessageEmbedOptions) { + options.author ??= {} + options.author.name = this.getChildrenText() + options.author.url = this.props.url + options.author.iconURL = this.props.iconUrl + } +} diff --git a/packages/reacord/src/embed.tsx b/packages/reacord/src/embed.tsx index bae5652..90ff810 100644 --- a/packages/reacord/src/embed.tsx +++ b/packages/reacord/src/embed.tsx @@ -33,9 +33,26 @@ class EmbedInstance extends ContainerInstance { } get embedOptions(): MessageEmbedOptions { - return { + /* eslint-disable unicorn/no-null */ + const options: MessageEmbedOptions = { color: this.color, - description: this.getChildrenText() || "_ _", + description: null as unknown as undefined, } + /* eslint-enable unicorn/no-null */ + + for (const child of this.children) { + if (!child.renderToEmbed) { + console.warn(`${child.name} is not a valid child of ${this.name}`) + continue + } + child.renderToEmbed(options) + } + + // can't render an empty embed + if (!options.description) { + options.description = "_ _" + } + + return options } } diff --git a/packages/reacord/src/main.ts b/packages/reacord/src/main.ts index 67e81d1..2edf69b 100644 --- a/packages/reacord/src/main.ts +++ b/packages/reacord/src/main.ts @@ -1,3 +1,4 @@ +export * from "./embed-author.js" export * from "./embed.js" export * from "./root.js" export * from "./text.js" diff --git a/packages/reacord/src/root.ts b/packages/reacord/src/root.ts index b105595..9043295 100644 --- a/packages/reacord/src/root.ts +++ b/packages/reacord/src/root.ts @@ -6,6 +6,8 @@ import { reconciler } from "./reconciler" export type ReacordRenderTarget = TextBasedChannels +export type ReacordRoot = ReturnType + export function createRoot(target: ReacordRenderTarget) { const container = new ReacordContainer(target) const containerId = reconciler.createContainer(container, 0, false, null) diff --git a/packages/reacord/src/text-instance.ts b/packages/reacord/src/text-instance.ts index 5305f5f..1f8be1c 100644 --- a/packages/reacord/src/text-instance.ts +++ b/packages/reacord/src/text-instance.ts @@ -1,4 +1,4 @@ -import type { MessageOptions } from "discord.js" +import type { MessageEmbedOptions, MessageOptions } from "discord.js" import { BaseInstance } from "./base-instance.js" /** Represents raw strings in JSX */ @@ -16,4 +16,8 @@ export class TextInstance extends BaseInstance { override renderToMessage(options: MessageOptions) { options.content = (options.content ?? "") + this.getText() } + + override renderToEmbed(options: MessageEmbedOptions) { + options.description = (options.description ?? "") + this.getText() + } } diff --git a/packages/reacord/src/text.tsx b/packages/reacord/src/text.tsx index af28a78..89bad7d 100644 --- a/packages/reacord/src/text.tsx +++ b/packages/reacord/src/text.tsx @@ -1,4 +1,4 @@ -import type { MessageOptions } from "discord.js" +import type { MessageEmbedOptions, MessageOptions } from "discord.js" import type { ReactNode } from "react" import React from "react" import { ContainerInstance } from "./container-instance.js" @@ -29,4 +29,8 @@ class TextElementInstance extends ContainerInstance { override renderToMessage(options: MessageOptions) { options.content = (options.content ?? "") + this.getText() } + + override renderToEmbed(options: MessageEmbedOptions) { + options.description = (options.description ?? "") + this.getText() + } }