This commit is contained in:
itsMapleLeaf
2022-07-24 20:41:25 -05:00
parent 4b6de3ab5f
commit 06a8976d8e
9 changed files with 319 additions and 44 deletions

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from "react"
/**
* Common props between button-like components
* @category Button
*/
export type ButtonSharedProps = {
/** The text on the button. Rich formatting (markdown) is not supported here. */
label?: 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
}

View File

@@ -0,0 +1,51 @@
import { randomUUID } from "node:crypto"
import React from "react"
import type { ButtonSharedProps } from "./button-shared-props"
import type { ComponentEvent } from "./component-event"
import { Container } from "./container"
import type { Node, NodeContainer } from "./node"
import { TextNode } from "./node"
import { ReacordElement } from "./reacord-element"
/**
* @category Button
*/
export type ButtonProps = ButtonSharedProps & {
/**
* 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
export function Button({ label, ...props }: ButtonProps) {
return (
<ReacordElement createNode={() => new ButtonNode(props)} nodeProps={props}>
{label}
</ReacordElement>
)
}
export class ButtonNode implements Node<ButtonProps> {
readonly children: NodeContainer = new Container()
readonly customId = randomUUID()
constructor(readonly props: ButtonProps) {}
get label(): string {
return this.children
.getItems()
.map((child) => (child instanceof TextNode ? child.props.text : ""))
.join("")
}
}

View File

@@ -0,0 +1,113 @@
import type { ReactNode } from "react"
import type { ReacordInstance } from "./reacord-instance-pool"
/**
* @category Component Event
*/
export type ComponentEvent = {
/**
* The message associated with this event.
* For example: with a button click,
* this is the message that the button is on.
* @see https://discord.com/developers/docs/resources/channel#message-object
*/
message: MessageInfo
/**
* The channel that this event occurred in.
* @see https://discord.com/developers/docs/resources/channel#channel-object
*/
channel: ChannelInfo
/**
* The user that triggered this event.
* @see https://discord.com/developers/docs/resources/user#user-object
*/
user: UserInfo
/**
* The guild that this event occurred in.
* @see https://discord.com/developers/docs/resources/guild#guild-object
*/
guild?: GuildInfo
/**
* 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
}
/**
* @category Component Event
*/
export type ChannelInfo = {
id: string
name?: string
topic?: string
nsfw?: boolean
lastMessageId?: string
ownerId?: string
parentId?: string
rateLimitPerUser?: number
}
/**
* @category Component Event
*/
export type MessageInfo = {
id: string
channelId: string
authorId: UserInfo
member?: GuildMemberInfo
content: string
timestamp: string
editedTimestamp?: string
tts: boolean
mentionEveryone: boolean
/** The IDs of mentioned users */
mentions: string[]
}
/**
* @category Component Event
*/
export type GuildInfo = {
id: string
name: string
member: GuildMemberInfo
}
/**
* @category Component Event
*/
export type GuildMemberInfo = {
id: string
nick?: string
displayName: string
avatarUrl?: string
displayAvatarUrl: string
roles: string[]
color: number
joinedAt?: string
premiumSince?: string
pending?: boolean
communicationDisabledUntil?: string
}
/**
* @category Component Event
*/
export type UserInfo = {
id: string
username: string
discriminator: string
tag: string
avatarUrl: string
accentColor?: number
}

View File

@@ -1,20 +1,32 @@
export type Node = {
readonly type: string
readonly props?: Record<string, unknown>
children?: Node[]
getText?: () => string
import type { Container } from "./container"
export type NodeContainer = Container<Node<unknown>>
export type Node<Props> = {
props?: Props
children?: NodeContainer
}
export class TextNode implements Node {
readonly type = "text"
constructor(private text: string) {}
getText() {
return this.text
}
setText(text: string) {
this.text = text
export class TextNode implements Node<{ text: string }> {
props: { text: string }
constructor(text: string) {
this.props = { text }
}
}
export class NodeDefinition<Props> {
static parse(value: unknown): NodeDefinition<unknown> {
if (value instanceof NodeDefinition) {
return value
}
const received = value as Object | null | undefined
throw new TypeError(
`Expected ${NodeDefinition.name}, received instance of ${received?.constructor.name}`,
)
}
constructor(
public readonly create: () => Node<Props>,
public readonly props: Props,
) {}
}

View File

@@ -6,9 +6,13 @@ import type {
MessageOptions,
TextBasedChannel,
} from "discord.js"
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"
import type { ReactNode } from "react"
import { AsyncQueue } from "./async-queue"
import type { ButtonProps } from "./button"
import { ButtonNode } from "./button"
import type { Node } from "./node"
import { TextNode } from "./node"
import type {
ReacordMessageRenderer,
ReacordOptions,
@@ -43,9 +47,37 @@ class ChannelMessageRenderer implements ReacordMessageRenderer {
private readonly channelId: string,
) {}
update(nodes: readonly Node[]) {
update(nodes: ReadonlyArray<Node<unknown>>) {
const rows: Array<ActionRowBuilder<ButtonBuilder>> = []
for (const node of nodes) {
if (node instanceof ButtonNode) {
let currentRow = rows[rows.length - 1]
if (!currentRow || currentRow.components.length === 5) {
currentRow = new ActionRowBuilder()
rows.push(currentRow)
}
currentRow.addComponents(
new ButtonBuilder({
label: node.label,
customId: node.customId,
emoji: node.props.emoji,
disabled: node.props.disabled,
style: node.props.style
? getButtonStyle(node.props.style)
: ButtonStyle.Secondary,
}),
)
}
}
const options: MessageOptions & MessageEditOptions = {
content: nodes.map((node) => node.getText?.() || "").join(""),
content: nodes
.map((node) => (node instanceof TextNode ? node.props.text : ""))
.join(""),
components: rows.length > 0 ? rows : undefined,
}
return this.queue.add(async () => {
@@ -95,3 +127,13 @@ class ChannelMessageRenderer implements ReacordMessageRenderer {
return (this.channel = channel)
}
}
function getButtonStyle(style: NonNullable<ButtonProps["style"]>) {
const styleMap = {
primary: ButtonStyle.Primary,
secondary: ButtonStyle.Secondary,
danger: ButtonStyle.Danger,
success: ButtonStyle.Success,
} as const
return styleMap[style]
}

View File

@@ -0,0 +1,20 @@
import type { ReactNode } from "react"
import React from "react"
import type { Node } from "./node"
import { NodeDefinition } from "./node"
export function ReacordElement<Props>({
children,
createNode,
nodeProps,
}: {
createNode: () => Node<Props>
nodeProps: Props
children?: ReactNode
}) {
return React.createElement(
"reacord-element",
{ definition: new NodeDefinition(createNode, nodeProps) },
children,
)
}

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react"
import { Container } from "./container"
import type { Node } from "./node"
import type { Node, NodeContainer } from "./node"
import { reconciler } from "./reconciler"
export type ReacordOptions = {
@@ -31,7 +31,7 @@ export type ReacordInstanceOptions = {
}
export type ReacordMessageRenderer = {
update: (nodes: readonly Node[]) => Promise<void>
update: (nodes: ReadonlyArray<Node<unknown>>) => Promise<void>
deactivate: () => Promise<void>
destroy: () => Promise<void>
}
@@ -45,7 +45,7 @@ export class ReacordInstancePool {
}
create({ initialContent, renderer }: ReacordInstanceOptions) {
const nodes = new Container<Node>()
const nodes: NodeContainer = new Container()
const render = async () => {
try {

View File

@@ -1,14 +1,13 @@
import ReactReconciler from "react-reconciler"
import { DefaultEventPriority } from "react-reconciler/constants"
import type { Container } from "./container"
import type { Node } from "./node"
import { TextNode } from "./node"
import type { Node, NodeContainer } from "./node"
import { NodeDefinition, TextNode } from "./node"
export const reconciler = ReactReconciler<
string, // Type
Record<string, unknown>, // Props
{ nodes: Container<Node>; render: () => void }, // Container
never, // Instance
{ definition?: unknown }, // Props
{ nodes: NodeContainer; render: () => void }, // Container
Node<unknown>, // Instance
TextNode, // TextInstance
never, // SuspenseInstance
never, // HydratableInstance
@@ -27,29 +26,37 @@ export const reconciler = ReactReconciler<
cancelTimeout: clearTimeout,
noTimeout: -1,
createInstance() {
throw new Error("Not implemented")
createInstance(type, props) {
return NodeDefinition.parse(props.definition).create()
},
createTextInstance(text) {
return new TextNode(text)
},
appendInitialChild(parent, child) {},
appendInitialChild(parent, child) {
parent.children?.add(child)
},
appendChild(parentInstance, child) {},
appendChild(parent, child) {
parent.children?.add(child)
},
appendChildToContainer(container, child) {
container.nodes.add(child)
},
insertBefore(parentInstance, child, beforeChild) {},
insertBefore(parent, child, beforeChild) {
parent.children?.insertBefore(child, beforeChild)
},
insertInContainerBefore(container, child, beforeChild) {
container.nodes.insertBefore(child, beforeChild)
},
removeChild(parentInstance, child) {},
removeChild(parent, child) {
parent.children?.remove(child)
},
removeChildFromContainer(container, child) {
container.nodes.remove(child)
@@ -59,18 +66,13 @@ export const reconciler = ReactReconciler<
container.nodes.clear()
},
commitTextUpdate(textInstance, oldText, newText) {
textInstance.setText(newText)
commitTextUpdate(node, oldText, newText) {
node.props.text = newText
},
commitUpdate(
instance,
updatePayload,
type,
prevProps,
nextProps,
internalHandle,
) {},
commitUpdate(node, updatePayload, type, prevProps, nextProps) {
node.props = NodeDefinition.parse(nextProps.definition).props
},
prepareForCommit() {
// eslint-disable-next-line unicorn/no-null

View File

@@ -6,7 +6,7 @@ import prettyMilliseconds from "pretty-ms"
import React, { useEffect, useState } from "react"
import { raise } from "../helpers/raise"
import { waitFor } from "../helpers/wait-for"
import { ReacordDiscordJs } from "../library.new/main"
import { Button, ReacordDiscordJs } from "../library.new/main"
const client = new Client({ intents: IntentsBitField.Flags.Guilds })
const reacord = new ReacordDiscordJs(client)
@@ -41,6 +41,17 @@ const createTest = async (
await block(channel)
}
await createTest("components", "test 'dem buttons", async (channel) => {
reacord.send(
channel.id,
<>
{Array.from({ length: 6 }, (_, i) => (
<Button key={i} label={String(i + 1)} onClick={() => {}} />
))}
</>,
)
})
await createTest("basic", "should update over time", (channel) => {
function Timer() {
const [startDate] = useState(() => new Date())