This commit is contained in:
itsMapleLeaf
2022-07-31 23:43:32 -05:00
parent 98d6f59fe4
commit cbd9120c34
10 changed files with 472 additions and 0 deletions

View File

@@ -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 (
<ReacordElement createNode={() => new ButtonNode(props)}>
{label ?? children}
</ReacordElement>
)
}
export class ButtonNode extends Node<ButtonProps> {
readonly customId = randomUUID()
}

View File

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

View File

@@ -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 <reacord-embed {...props}>{children}</reacord-embed>
}
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface ReacordHostElementMap {
"reacord-embed": Except<EmbedProps, "children">
}
}

View File

@@ -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<APIActionRowComponent<APIMessageActionRowComponent>>
}
export function makeMessageUpdatePayload(
tree: HostElement<keyof ReacordHostElementMap>,
): MessageUpdatePayload {
return {
content: tree.children
.map((child) => (child.type === "reacord-text" ? child.props.text : ""))
.join(""),
embeds: tree.children.flatMap<APIEmbed>((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: [],
}
}

View File

@@ -0,0 +1,45 @@
export class Node<Props = unknown> {
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<Node> {
yield this
for (const child of this.children) {
yield* child.walk()
}
}
}

View File

@@ -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<ReacordElementHostProps>(
"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}`,
)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import { Node } from "./node.js"
export class TextNode extends Node {
constructor(public text: string) {
super()
}
}

View File

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