.new.new
This commit is contained in:
61
packages/reacord/library.new.new/core/button.tsx
Normal file
61
packages/reacord/library.new.new/core/button.tsx
Normal 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()
|
||||
}
|
||||
18
packages/reacord/library.new.new/core/component-event.ts
Normal file
18
packages/reacord/library.new.new/core/component-event.ts
Normal 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
|
||||
}
|
||||
29
packages/reacord/library.new.new/core/embed.tsx
Normal file
29
packages/reacord/library.new.new/core/embed.tsx
Normal 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">
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
45
packages/reacord/library.new.new/core/node.ts
Normal file
45
packages/reacord/library.new.new/core/node.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
31
packages/reacord/library.new.new/core/reacord-element.ts
Normal file
31
packages/reacord/library.new.new/core/reacord-element.ts
Normal 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}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
36
packages/reacord/library.new.new/core/reacord-instance.ts
Normal file
36
packages/reacord/library.new.new/core/reacord-instance.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
139
packages/reacord/library.new.new/core/reconciler.ts
Normal file
139
packages/reacord/library.new.new/core/reconciler.ts
Normal 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() {},
|
||||
})
|
||||
7
packages/reacord/library.new.new/core/text-node.ts
Normal file
7
packages/reacord/library.new.new/core/text-node.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Node } from "./node.js"
|
||||
|
||||
export class TextNode extends Node {
|
||||
constructor(public text: string) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
69
packages/reacord/library.new.new/djs/reacord-discord-js.ts
Normal file
69
packages/reacord/library.new.new/djs/reacord-discord-js.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user