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 = {
|
import type { Container } from "./container"
|
||||||
readonly type: string
|
|
||||||
readonly props?: Record<string, unknown>
|
export type NodeContainer = Container<Node<unknown>>
|
||||||
children?: Node[]
|
|
||||||
getText?: () => string
|
export type Node<Props> = {
|
||||||
|
props?: Props
|
||||||
|
children?: NodeContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TextNode implements Node {
|
export class TextNode implements Node<{ text: string }> {
|
||||||
readonly type = "text"
|
props: { text: string }
|
||||||
|
constructor(text: string) {
|
||||||
constructor(private text: string) {}
|
this.props = { text }
|
||||||
|
|
||||||
getText() {
|
|
||||||
return this.text
|
|
||||||
}
|
|
||||||
|
|
||||||
setText(text: string) {
|
|
||||||
this.text = 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,
|
MessageOptions,
|
||||||
TextBasedChannel,
|
TextBasedChannel,
|
||||||
} from "discord.js"
|
} from "discord.js"
|
||||||
|
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { AsyncQueue } from "./async-queue"
|
import { AsyncQueue } from "./async-queue"
|
||||||
|
import type { ButtonProps } from "./button"
|
||||||
|
import { ButtonNode } from "./button"
|
||||||
import type { Node } from "./node"
|
import type { Node } from "./node"
|
||||||
|
import { TextNode } from "./node"
|
||||||
import type {
|
import type {
|
||||||
ReacordMessageRenderer,
|
ReacordMessageRenderer,
|
||||||
ReacordOptions,
|
ReacordOptions,
|
||||||
@@ -43,9 +47,37 @@ class ChannelMessageRenderer implements ReacordMessageRenderer {
|
|||||||
private readonly channelId: string,
|
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 = {
|
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 () => {
|
return this.queue.add(async () => {
|
||||||
@@ -95,3 +127,13 @@ class ChannelMessageRenderer implements ReacordMessageRenderer {
|
|||||||
return (this.channel = channel)
|
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 type { ReactNode } from "react"
|
||||||
import { Container } from "./container"
|
import { Container } from "./container"
|
||||||
import type { Node } from "./node"
|
import type { Node, NodeContainer } from "./node"
|
||||||
import { reconciler } from "./reconciler"
|
import { reconciler } from "./reconciler"
|
||||||
|
|
||||||
export type ReacordOptions = {
|
export type ReacordOptions = {
|
||||||
@@ -31,7 +31,7 @@ export type ReacordInstanceOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ReacordMessageRenderer = {
|
export type ReacordMessageRenderer = {
|
||||||
update: (nodes: readonly Node[]) => Promise<void>
|
update: (nodes: ReadonlyArray<Node<unknown>>) => Promise<void>
|
||||||
deactivate: () => Promise<void>
|
deactivate: () => Promise<void>
|
||||||
destroy: () => Promise<void>
|
destroy: () => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ export class ReacordInstancePool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create({ initialContent, renderer }: ReacordInstanceOptions) {
|
create({ initialContent, renderer }: ReacordInstanceOptions) {
|
||||||
const nodes = new Container<Node>()
|
const nodes: NodeContainer = new Container()
|
||||||
|
|
||||||
const render = async () => {
|
const render = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import ReactReconciler from "react-reconciler"
|
import ReactReconciler from "react-reconciler"
|
||||||
import { DefaultEventPriority } from "react-reconciler/constants"
|
import { DefaultEventPriority } from "react-reconciler/constants"
|
||||||
import type { Container } from "./container"
|
import type { Node, NodeContainer } from "./node"
|
||||||
import type { Node } from "./node"
|
import { NodeDefinition, TextNode } from "./node"
|
||||||
import { TextNode } from "./node"
|
|
||||||
|
|
||||||
export const reconciler = ReactReconciler<
|
export const reconciler = ReactReconciler<
|
||||||
string, // Type
|
string, // Type
|
||||||
Record<string, unknown>, // Props
|
{ definition?: unknown }, // Props
|
||||||
{ nodes: Container<Node>; render: () => void }, // Container
|
{ nodes: NodeContainer; render: () => void }, // Container
|
||||||
never, // Instance
|
Node<unknown>, // Instance
|
||||||
TextNode, // TextInstance
|
TextNode, // TextInstance
|
||||||
never, // SuspenseInstance
|
never, // SuspenseInstance
|
||||||
never, // HydratableInstance
|
never, // HydratableInstance
|
||||||
@@ -27,29 +26,37 @@ export const reconciler = ReactReconciler<
|
|||||||
cancelTimeout: clearTimeout,
|
cancelTimeout: clearTimeout,
|
||||||
noTimeout: -1,
|
noTimeout: -1,
|
||||||
|
|
||||||
createInstance() {
|
createInstance(type, props) {
|
||||||
throw new Error("Not implemented")
|
return NodeDefinition.parse(props.definition).create()
|
||||||
},
|
},
|
||||||
|
|
||||||
createTextInstance(text) {
|
createTextInstance(text) {
|
||||||
return new TextNode(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) {
|
appendChildToContainer(container, child) {
|
||||||
container.nodes.add(child)
|
container.nodes.add(child)
|
||||||
},
|
},
|
||||||
|
|
||||||
insertBefore(parentInstance, child, beforeChild) {},
|
insertBefore(parent, child, beforeChild) {
|
||||||
|
parent.children?.insertBefore(child, beforeChild)
|
||||||
|
},
|
||||||
|
|
||||||
insertInContainerBefore(container, child, beforeChild) {
|
insertInContainerBefore(container, child, beforeChild) {
|
||||||
container.nodes.insertBefore(child, beforeChild)
|
container.nodes.insertBefore(child, beforeChild)
|
||||||
},
|
},
|
||||||
|
|
||||||
removeChild(parentInstance, child) {},
|
removeChild(parent, child) {
|
||||||
|
parent.children?.remove(child)
|
||||||
|
},
|
||||||
|
|
||||||
removeChildFromContainer(container, child) {
|
removeChildFromContainer(container, child) {
|
||||||
container.nodes.remove(child)
|
container.nodes.remove(child)
|
||||||
@@ -59,18 +66,13 @@ export const reconciler = ReactReconciler<
|
|||||||
container.nodes.clear()
|
container.nodes.clear()
|
||||||
},
|
},
|
||||||
|
|
||||||
commitTextUpdate(textInstance, oldText, newText) {
|
commitTextUpdate(node, oldText, newText) {
|
||||||
textInstance.setText(newText)
|
node.props.text = newText
|
||||||
},
|
},
|
||||||
|
|
||||||
commitUpdate(
|
commitUpdate(node, updatePayload, type, prevProps, nextProps) {
|
||||||
instance,
|
node.props = NodeDefinition.parse(nextProps.definition).props
|
||||||
updatePayload,
|
},
|
||||||
type,
|
|
||||||
prevProps,
|
|
||||||
nextProps,
|
|
||||||
internalHandle,
|
|
||||||
) {},
|
|
||||||
|
|
||||||
prepareForCommit() {
|
prepareForCommit() {
|
||||||
// eslint-disable-next-line unicorn/no-null
|
// eslint-disable-next-line unicorn/no-null
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import prettyMilliseconds from "pretty-ms"
|
|||||||
import React, { useEffect, useState } from "react"
|
import React, { useEffect, useState } from "react"
|
||||||
import { raise } from "../helpers/raise"
|
import { raise } from "../helpers/raise"
|
||||||
import { waitFor } from "../helpers/wait-for"
|
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 client = new Client({ intents: IntentsBitField.Flags.Guilds })
|
||||||
const reacord = new ReacordDiscordJs(client)
|
const reacord = new ReacordDiscordJs(client)
|
||||||
@@ -41,6 +41,17 @@ const createTest = async (
|
|||||||
await block(channel)
|
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) => {
|
await createTest("basic", "should update over time", (channel) => {
|
||||||
function Timer() {
|
function Timer() {
|
||||||
const [startDate] = useState(() => new Date())
|
const [startDate] = useState(() => new Date())
|
||||||
|
|||||||
Reference in New Issue
Block a user