buttons
This commit is contained in:
24
packages/reacord/library.new/button-shared-props.ts
Normal file
24
packages/reacord/library.new/button-shared-props.ts
Normal 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
|
||||
}
|
||||
51
packages/reacord/library.new/button.tsx
Normal file
51
packages/reacord/library.new/button.tsx
Normal 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("")
|
||||
}
|
||||
}
|
||||
113
packages/reacord/library.new/component-event.ts
Normal file
113
packages/reacord/library.new/component-event.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
20
packages/reacord/library.new/reacord-element.ts
Normal file
20
packages/reacord/library.new/reacord-element.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user