button onClick

This commit is contained in:
MapleLeaf
2021-12-22 12:57:15 -06:00
parent 067b9b43a1
commit b3b0fdc279
4 changed files with 133 additions and 26 deletions

View File

@@ -1,9 +1,11 @@
/* eslint-disable unicorn/no-null */ /* eslint-disable unicorn/no-null */
import type { Message, MessageOptions } from "discord.js" import type { ButtonInteraction, Message, MessageOptions } from "discord.js"
import { Client, TextChannel } from "discord.js" import { Client, TextChannel } from "discord.js"
import { nanoid } from "nanoid"
import React from "react" import React from "react"
import { omit } from "../src/helpers/omit.js" import { omit } from "../src/helpers/omit.js"
import { raise } from "../src/helpers/raise.js" import { raise } from "../src/helpers/raise.js"
import { waitForWithTimeout } from "../src/helpers/wait-for-with-timeout.js"
import type { ReacordRoot } from "../src/main.js" import type { ReacordRoot } from "../src/main.js"
import { import {
ActionRow, ActionRow,
@@ -112,17 +114,27 @@ test("kitchen sink", async () => {
field content but inline field content but inline
</EmbedField> </EmbedField>
</Embed> </Embed>
<Button style="primary">primary button</Button> <Button onClick={() => {}} style="primary">
<Button style="danger">danger button</Button> primary button
<Button style="success">success button</Button> </Button>
<Button style="secondary">secondary button</Button> <Button onClick={() => {}} style="danger">
<Button>secondary by default</Button> danger button
<Button> </Button>
<Button onClick={() => {}} style="success">
success button
</Button>
<Button onClick={() => {}} style="secondary">
secondary button
</Button>
<Button onClick={() => {}}>secondary by default</Button>
<Button onClick={() => {}}>
complex <Text>button</Text> text complex <Text>button</Text> text
</Button> </Button>
<Button disabled>disabled button</Button> <Button onClick={() => {}} disabled>
disabled button
</Button>
<ActionRow> <ActionRow>
<Button>new action row</Button> <Button onClick={() => {}}>new action row</Button>
</ActionRow> </ActionRow>
</>, </>,
) )
@@ -231,6 +243,31 @@ test("kitchen sink", async () => {
]) ])
}) })
test("button onClick", async () => {
let clicked = false
await root.render(<Button onClick={() => (clicked = true)} />)
const messages = await channel.messages.fetch()
const customId =
messages.first()?.components[0]?.components[0]?.customId ??
raise("Message not created")
client.emit("interactionCreate", {
id: nanoid(),
type: "MESSAGE_COMPONENT",
componentType: "BUTTON",
channelId: channel.id,
guildId: channel.guildId,
isButton: () => true,
customId,
user: { id: "123" },
} as ButtonInteraction)
await waitForWithTimeout(() => clicked, 1000)
})
test("destroy", async () => { test("destroy", async () => {
await root.destroy() await root.destroy()
await assertMessages([]) await assertMessages([])

View File

@@ -1,4 +1,9 @@
import type { EmojiResolvable, MessageButtonStyle } from "discord.js" import type {
ButtonInteraction,
EmojiResolvable,
MessageButtonStyle,
} from "discord.js"
import { nanoid } from "nanoid"
import React from "react" import React from "react"
export type ButtonStyle = Exclude<Lowercase<MessageButtonStyle>, "link"> export type ButtonStyle = Exclude<Lowercase<MessageButtonStyle>, "link">
@@ -7,13 +12,19 @@ export type ButtonProps = {
style?: ButtonStyle style?: ButtonStyle
emoji?: EmojiResolvable emoji?: EmojiResolvable
disabled?: boolean disabled?: boolean
onClick: (interaction: ButtonInteraction) => void
children?: React.ReactNode children?: React.ReactNode
} }
export function Button(props: ButtonProps) { export function Button(props: ButtonProps) {
return ( return (
<reacord-element <reacord-element
createNode={() => ({ ...props, type: "button", children: [] })} createNode={() => ({
...props,
type: "button",
children: [],
customId: nanoid(),
})}
> >
{props.children} {props.children}
</reacord-element> </reacord-element>

View File

@@ -1,12 +1,12 @@
import type { import type {
BaseMessageComponentOptions, BaseMessageComponentOptions,
ButtonInteraction,
ColorResolvable, ColorResolvable,
EmojiResolvable, EmojiResolvable,
MessageActionRowOptions, MessageActionRowOptions,
MessageEmbedOptions, MessageEmbedOptions,
MessageOptions, MessageOptions,
} from "discord.js" } from "discord.js"
import { nanoid } from "nanoid"
import type { ButtonStyle } from "./components/button.js" import type { ButtonStyle } from "./components/button.js"
import { last } from "./helpers/last.js" import { last } from "./helpers/last.js"
import { toUpper } from "./helpers/to-upper.js" import { toUpper } from "./helpers/to-upper.js"
@@ -63,6 +63,8 @@ type ButtonNode = {
style?: ButtonStyle style?: ButtonStyle
emoji?: EmojiResolvable emoji?: EmojiResolvable
disabled?: boolean disabled?: boolean
customId: string
onClick: (interaction: ButtonInteraction) => void
children: Node[] children: Node[]
} }
@@ -181,12 +183,30 @@ function addActionRowItems(components: ActionRowOptions[], nodes: Node[]) {
if (node.type === "button") { if (node.type === "button") {
actionRow.components.push({ actionRow.components.push({
type: "BUTTON", type: "BUTTON",
label: node.children.map(getNodeText).join(""), label: node.children.map(getNodeText).join("") || "_ _",
style: node.style ? toUpper(node.style) : "SECONDARY", style: node.style ? toUpper(node.style) : "SECONDARY",
emoji: node.emoji, emoji: node.emoji,
disabled: node.disabled, disabled: node.disabled,
customId: nanoid(), customId: node.customId,
}) })
} }
} }
} }
type InteractionHandler = {
type: "button"
customId: string
onClick: (interaction: ButtonInteraction) => void
}
export function collectInteractionHandlers(node: Node): InteractionHandler[] {
if (node.type === "button") {
return [{ type: "button", customId: node.customId, onClick: node.onClick }]
}
if ("children" in node) {
return node.children.flatMap(collectInteractionHandlers)
}
return []
}

View File

@@ -1,31 +1,65 @@
import type { Message, MessageOptions, TextBasedChannels } from "discord.js" import type {
InteractionCollector,
Message,
MessageComponentInteraction,
MessageComponentType,
TextBasedChannels,
} from "discord.js"
import type { MessageNode } from "./node-tree.js" import type { MessageNode } from "./node-tree.js"
import { getMessageOptions } from "./node-tree.js" import { collectInteractionHandlers, getMessageOptions } from "./node-tree.js"
type Action = type Action =
| { type: "updateMessage"; options: MessageOptions } | { type: "updateMessage"; tree: MessageNode }
| { type: "deleteMessage" } | { type: "deleteMessage" }
export class MessageRenderer { export class MessageRenderer {
private channel: TextBasedChannels private channel: TextBasedChannels
private interactionCollector: InteractionCollector<MessageComponentInteraction>
private message?: Message private message?: Message
private tree?: MessageNode
private actions: Action[] = [] private actions: Action[] = []
private runningPromise?: Promise<void> private runningPromise?: Promise<void>
constructor(channel: TextBasedChannels) { constructor(channel: TextBasedChannels) {
this.channel = channel this.channel = channel
this.interactionCollector =
this.createInteractionCollector() as InteractionCollector<MessageComponentInteraction>
}
private getInteractionHandler(customId: string) {
if (!this.tree) return undefined
const handlers = collectInteractionHandlers(this.tree)
return handlers.find((handler) => handler.customId === customId)
}
private createInteractionCollector() {
const collector =
this.channel.createMessageComponentCollector<MessageComponentType>({
filter: (interaction) =>
!!this.getInteractionHandler(interaction.customId),
})
collector.on("collect", (interaction) => {
const handler = this.getInteractionHandler(interaction.customId)
if (handler?.type === "button" && interaction.isButton()) {
handler.onClick(interaction)
}
})
return collector
} }
render(node: MessageNode) { render(node: MessageNode) {
this.addAction({ this.addAction({
type: "updateMessage", type: "updateMessage",
options: getMessageOptions(node), tree: node,
}) })
} }
destroy() { destroy() {
this.actions = [] this.actions = []
this.addAction({ type: "deleteMessage" }) this.addAction({ type: "deleteMessage" })
this.interactionCollector.stop()
} }
completion() { completion() {
@@ -65,15 +99,20 @@ export class MessageRenderer {
private async runAction(action: Action) { private async runAction(action: Action) {
if (action.type === "updateMessage") { if (action.type === "updateMessage") {
this.message = await (this.message const options = getMessageOptions(action.tree)
? this.message.edit({ // eslint-disable-next-line unicorn/prefer-ternary
...action.options, if (this.message) {
this.message = await this.message.edit({
...options,
// need to ensure that, if there's no text, it's erased // need to ensure that, if there's no text, it's erased
// eslint-disable-next-line unicorn/no-null // eslint-disable-next-line unicorn/no-null
content: action.options.content ?? null, content: options.content ?? null,
}) })
: this.channel.send(action.options)) } else {
this.message = await this.channel.send(options)
}
this.tree = action.tree
} }
if (action.type === "deleteMessage") { if (action.type === "deleteMessage") {