.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