From cbd9120c34738c069041f728f19287a60652f59d Mon Sep 17 00:00:00 2001 From: itsMapleLeaf <19603573+itsMapleLeaf@users.noreply.github.com> Date: Sun, 31 Jul 2022 23:43:32 -0500 Subject: [PATCH] .new.new --- .../reacord/library.new.new/core/button.tsx | 61 ++++++++ .../library.new.new/core/component-event.ts | 18 +++ .../reacord/library.new.new/core/embed.tsx | 29 ++++ .../core/make-message-update-payload.ts | 37 +++++ packages/reacord/library.new.new/core/node.ts | 45 ++++++ .../library.new.new/core/reacord-element.ts | 31 ++++ .../library.new.new/core/reacord-instance.ts | 36 +++++ .../library.new.new/core/reconciler.ts | 139 ++++++++++++++++++ .../reacord/library.new.new/core/text-node.ts | 7 + .../library.new.new/djs/reacord-discord-js.ts | 69 +++++++++ 10 files changed, 472 insertions(+) create mode 100644 packages/reacord/library.new.new/core/button.tsx create mode 100644 packages/reacord/library.new.new/core/component-event.ts create mode 100644 packages/reacord/library.new.new/core/embed.tsx create mode 100644 packages/reacord/library.new.new/core/make-message-update-payload.ts create mode 100644 packages/reacord/library.new.new/core/node.ts create mode 100644 packages/reacord/library.new.new/core/reacord-element.ts create mode 100644 packages/reacord/library.new.new/core/reacord-instance.ts create mode 100644 packages/reacord/library.new.new/core/reconciler.ts create mode 100644 packages/reacord/library.new.new/core/text-node.ts create mode 100644 packages/reacord/library.new.new/djs/reacord-discord-js.ts diff --git a/packages/reacord/library.new.new/core/button.tsx b/packages/reacord/library.new.new/core/button.tsx new file mode 100644 index 0000000..5d236e3 --- /dev/null +++ b/packages/reacord/library.new.new/core/button.tsx @@ -0,0 +1,61 @@ +import type { APIMessageComponentButtonInteraction } from "discord.js" +import { randomUUID } from "node:crypto" +import type { ReactNode } from "react" +import React from "react" +import type { ComponentEvent } from "./component-event.js" +import { Node } from "./node.js" +import { ReacordElement } from "./reacord-element.js" + +export type ButtonProps = { + /** The text on the button. Rich formatting (markdown) is not supported here. */ + label?: ReactNode + + /** The text on the button. Rich formatting (markdown) is not supported here. + * If both `label` and `children` are passed, `children` will be ignored. + */ + children?: ReactNode + + /** When true, the button will be slightly faded, and cannot be clicked. */ + disabled?: boolean + + /** + * Renders an emoji to the left of the text. + * Has to be a literal emoji character (e.g. 🍍), + * or an emoji code, like `<:plus_one:778531744860602388>`. + * + * To get an emoji code, type your emoji in Discord chat + * with a backslash `\` in front. + * The bot has to be in the emoji's guild to use it. + */ + emoji?: string + + /** + * The style determines the color of the button and signals intent. + * @see https://discord.com/developers/docs/interactions/message-components#button-object-button-styles + */ + style?: "primary" | "secondary" | "success" | "danger" + + /** + * Happens when a user clicks the button. + */ + onClick: (event: ButtonClickEvent) => void +} + +/** + * @category Button + */ +export type ButtonClickEvent = ComponentEvent & { + interaction: APIMessageComponentButtonInteraction +} + +export function Button({ label, children, ...props }: ButtonProps) { + return ( + new ButtonNode(props)}> + {label ?? children} + + ) +} + +export class ButtonNode extends Node { + readonly customId = randomUUID() +} diff --git a/packages/reacord/library.new.new/core/component-event.ts b/packages/reacord/library.new.new/core/component-event.ts new file mode 100644 index 0000000..0dc8b4c --- /dev/null +++ b/packages/reacord/library.new.new/core/component-event.ts @@ -0,0 +1,18 @@ +import type { ReactNode } from "react" +import type { ReacordInstance } from "./reacord-instance" + +/** + * @category Component Event + */ +export type ComponentEvent = { + /** + * Create a new reply to this event. + */ + reply(content?: ReactNode): ReacordInstance + + /** + * Create an ephemeral reply to this event, + * shown only to the user who triggered it. + */ + ephemeralReply(content?: ReactNode): ReacordInstance +} diff --git a/packages/reacord/library.new.new/core/embed.tsx b/packages/reacord/library.new.new/core/embed.tsx new file mode 100644 index 0000000..c725f26 --- /dev/null +++ b/packages/reacord/library.new.new/core/embed.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react" +import React from "react" +import type { Except } from "type-fest" + +export type EmbedProps = { + title?: string + description?: string + url?: string + color?: number + fields?: Array<{ name: string; value: string; inline?: boolean }> + author?: { name: string; url?: string; iconUrl?: string } + thumbnail?: { url: string } + image?: { url: string } + video?: { url: string } + footer?: { text: string; iconUrl?: string } + timestamp?: string | number | Date + children?: ReactNode +} + +export function Embed({ children, ...props }: EmbedProps) { + return {children} +} + +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface ReacordHostElementMap { + "reacord-embed": Except + } +} diff --git a/packages/reacord/library.new.new/core/make-message-update-payload.ts b/packages/reacord/library.new.new/core/make-message-update-payload.ts new file mode 100644 index 0000000..2bc99c4 --- /dev/null +++ b/packages/reacord/library.new.new/core/make-message-update-payload.ts @@ -0,0 +1,37 @@ +import { omit } from "@reacord/helpers/omit" +import type { + APIActionRowComponent, + APIEmbed, + APIMessageActionRowComponent, +} from "discord.js" +import type { HostElement } from "./host-element.js" + +export type MessageUpdatePayload = { + content: string + embeds: APIEmbed[] + components: Array> +} + +export function makeMessageUpdatePayload( + tree: HostElement, +): MessageUpdatePayload { + return { + content: tree.children + .map((child) => (child.type === "reacord-text" ? child.props.text : "")) + .join(""), + + embeds: tree.children.flatMap((child) => { + if (child.type !== "reacord-embed") return [] + + const embed: APIEmbed = omit(child.props, ["timestamp"]) + + if (child.props.timestamp != undefined) { + embed.timestamp = new Date(child.props.timestamp).toISOString() + } + + return embed + }), + + components: [], + } +} diff --git a/packages/reacord/library.new.new/core/node.ts b/packages/reacord/library.new.new/core/node.ts new file mode 100644 index 0000000..4a49d22 --- /dev/null +++ b/packages/reacord/library.new.new/core/node.ts @@ -0,0 +1,45 @@ +export class Node { + private readonly _children: Node[] = [] + + constructor(public props: Props) {} + + get children(): readonly Node[] { + return this._children + } + + clear() { + this._children.splice(0) + } + + add(...nodes: Node[]) { + this._children.push(...nodes) + } + + remove(node: Node) { + const index = this._children.indexOf(node) + if (index !== -1) this._children.splice(index, 1) + } + + insertBefore(node: Node, beforeNode: Node) { + const index = this._children.indexOf(beforeNode) + if (index !== -1) this._children.splice(index, 0, node) + } + + replace(oldNode: Node, newNode: Node) { + const index = this._children.indexOf(oldNode) + if (index !== -1) this._children[index] = newNode + } + + clone(): this { + const cloned: this = new (this.constructor as any)() + cloned.add(...this.children.map((child) => child.clone())) + return cloned + } + + *walk(): Generator { + yield this + for (const child of this.children) { + yield* child.walk() + } + } +} diff --git a/packages/reacord/library.new.new/core/reacord-element.ts b/packages/reacord/library.new.new/core/reacord-element.ts new file mode 100644 index 0000000..bfcd960 --- /dev/null +++ b/packages/reacord/library.new.new/core/reacord-element.ts @@ -0,0 +1,31 @@ +import { createElement } from "react" +import type { Node } from "./node.js" + +export type ReacordElementHostProps = { + factory: ReacordElementFactory +} + +export function ReacordElement(props: { + createNode: () => Node + children?: React.ReactNode +}) { + return createElement( + "reacord-element", + { factory: new ReacordElementFactory(props.createNode) }, + props.children, + ) +} + +export class ReacordElementFactory { + constructor(public readonly createNode: () => Node) {} + + static unwrap(maybeFactory: unknown): Node { + if (maybeFactory instanceof ReacordElementFactory) { + return maybeFactory.createNode() + } + const received = (maybeFactory as any)?.constructor.name + throw new TypeError( + `Expected a ${ReacordElementFactory.name}, got ${received}`, + ) + } +} diff --git a/packages/reacord/library.new.new/core/reacord-instance.ts b/packages/reacord/library.new.new/core/reacord-instance.ts new file mode 100644 index 0000000..9343a75 --- /dev/null +++ b/packages/reacord/library.new.new/core/reacord-instance.ts @@ -0,0 +1,36 @@ +import type { ReactNode } from "react" +import type { ButtonClickEvent } from "./button.js" +import { ButtonNode } from "./button.js" +import type { HostElement } from "./host-element.js" +import { Node } from "./node.js" +import { reconciler } from "./reconciler.js" + +export type ReacordRenderer = { + updateMessage(tree: HostElement): Promise +} + +export class ReacordInstance { + readonly currentTree = new Node() + private latestTree?: Node + private readonly reconcilerContainer = reconciler.createContainer() + + constructor(private readonly renderer: ReacordRenderer) {} + + render(content?: ReactNode) { + reconciler.updateContainer(content, this.reconcilerContainer) + } + + async updateMessage(tree: Node) { + await this.renderer.updateMessage(tree) + this.latestTree = tree + } + + handleButtonInteraction(customId: string, event: ButtonClickEvent) { + if (!this.latestTree) return + for (const node of this.latestTree.walk()) { + if (node instanceof ButtonNode && node.customId === customId) { + node.props.onClick(event) + } + } + } +} diff --git a/packages/reacord/library.new.new/core/reconciler.ts b/packages/reacord/library.new.new/core/reconciler.ts new file mode 100644 index 0000000..a4531e8 --- /dev/null +++ b/packages/reacord/library.new.new/core/reconciler.ts @@ -0,0 +1,139 @@ +/* eslint-disable unicorn/prefer-modern-dom-apis */ +import ReactReconciler from "react-reconciler" +import { DefaultEventPriority } from "react-reconciler/constants" +import type { Node } from "./node.js" +import type { ReacordElementHostProps } from "./reacord-element.js" +import { ReacordElementFactory } from "./reacord-element.js" +import type { ReacordInstance } from "./reacord-instance.js" +import { TextNode } from "./text-node.js" + +// technically elements of any shape can go through the reconciler, +// so I'm typing this as unknown to ensure we validate the props +// before using them +type ReconcilerProps = { + [_ in keyof ReacordElementHostProps]?: unknown +} + +export const reconciler = ReactReconciler< + string, // Type + ReconcilerProps, // Props + ReacordInstance, // Container + Node, // Instance + TextNode, // TextInstance + never, // SuspenseInstance + never, // HydratableInstance + never, // PublicInstance + {}, // HostContext + true, // UpdatePayload + never, // ChildSet + NodeJS.Timeout, // TimeoutHandle + -1 // NoTimeout +>({ + isPrimaryRenderer: true, + supportsMutation: true, + supportsHydration: false, + supportsPersistence: false, + scheduleTimeout: setTimeout, + cancelTimeout: clearTimeout, + noTimeout: -1, + + createInstance(type, props) { + return ReacordElementFactory.unwrap(props.factory) + }, + + createTextInstance(text) { + return new TextNode(text) + }, + + appendInitialChild(parent, child) { + parent.add(child) + }, + + appendChild(parent, child) { + parent.add(child) + }, + + appendChildToContainer(container, child) { + container.currentTree.add(child) + }, + + insertBefore(parent, child, beforeChild) { + parent.insertBefore(child, beforeChild) + }, + + insertInContainerBefore(container, child, beforeChild) { + container.currentTree.insertBefore(child, beforeChild) + }, + + removeChild(parent, child) { + parent.remove(child) + }, + + removeChildFromContainer(container, child) { + container.currentTree.remove(child) + }, + + clearContainer(container) { + container.currentTree.clear() + }, + + commitTextUpdate(node, oldText, newText) { + node.text = newText + }, + + prepareUpdate() { + return true + }, + + commitUpdate(node, updatePayload, type, prevProps, nextProps) { + node.props = ReacordElementFactory.unwrap(nextProps.factory).props + }, + + prepareForCommit() { + // eslint-disable-next-line unicorn/no-null + return null + }, + + resetAfterCommit(container) { + container.updateMessage(container.currentTree.clone()).catch(console.error) + }, + + finalizeInitialChildren() { + return false + }, + + shouldSetTextContent() { + return false + }, + + getRootHostContext() { + return {} + }, + + getChildHostContext() { + return {} + }, + + getPublicInstance() { + throw new Error("Refs are not supported") + }, + + preparePortalMount() {}, + + getCurrentEventPriority() { + return DefaultEventPriority + }, + + getInstanceFromNode() { + return undefined + }, + + beforeActiveInstanceBlur() {}, + afterActiveInstanceBlur() {}, + prepareScopeUpdate() {}, + getInstanceFromScope() { + // eslint-disable-next-line unicorn/no-null + return null + }, + detachDeletedInstance() {}, +}) diff --git a/packages/reacord/library.new.new/core/text-node.ts b/packages/reacord/library.new.new/core/text-node.ts new file mode 100644 index 0000000..73002dd --- /dev/null +++ b/packages/reacord/library.new.new/core/text-node.ts @@ -0,0 +1,7 @@ +import { Node } from "./node.js" + +export class TextNode extends Node { + constructor(public text: string) { + super() + } +} diff --git a/packages/reacord/library.new.new/djs/reacord-discord-js.ts b/packages/reacord/library.new.new/djs/reacord-discord-js.ts new file mode 100644 index 0000000..af8ebe8 --- /dev/null +++ b/packages/reacord/library.new.new/djs/reacord-discord-js.ts @@ -0,0 +1,69 @@ +import type { + APIMessageComponentButtonInteraction, + Client, + Message, + TextChannel, +} from "discord.js" +import type { ReactNode } from "react" +import type { HostElement } from "../core/host-element.js" +import { makeMessageUpdatePayload } from "../core/make-message-update-payload.js" +import type { ReacordRenderer } from "../core/reacord-instance.js" +import { ReacordInstance } from "../core/reacord-instance.js" + +export class ReacordDiscordJs { + instances: ReacordInstance[] = [] + + constructor(private readonly client: Client) { + client.on("interactionCreate", (interaction) => { + if (!interaction.inGuild()) return + + if (interaction.isButton()) { + const json = + interaction.toJSON() as APIMessageComponentButtonInteraction + + for (const instance of this.instances) { + instance.handleButtonInteraction(interaction.customId, { + interaction: json, + reply: () => {}, + ephemeralReply: () => {}, + }) + } + } + + if (interaction.isSelectMenu()) { + // etc. + } + }) + } + + send(channelId: string, initialContent?: ReactNode) { + const instance = new ReacordInstance( + new ChannelMessageRenderer(this.client, channelId), + ) + if (initialContent !== undefined) { + instance.render(initialContent) + } + } +} + +class ChannelMessageRenderer implements ReacordRenderer { + private message?: Message + + constructor( + private readonly client: Client, + private readonly channelId: string, + ) {} + + async updateMessage(tree: HostElement) { + const payload = makeMessageUpdatePayload(tree) + + if (!this.message) { + const channel = (await this.client.channels.fetch( + this.channelId, + )) as TextChannel + this.message = await channel.send(payload) + } else { + await this.message.edit(payload) + } + } +}