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