component events
This commit is contained in:
8
library/core/component-event.ts
Normal file
8
library/core/component-event.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
import type { ReacordInstance } from "./instance"
|
||||||
|
|
||||||
|
export type ComponentEvent = {
|
||||||
|
// todo: add more info, like user, channel, member, guild, etc.
|
||||||
|
reply(content?: ReactNode): ReacordInstance
|
||||||
|
ephemeralReply(content?: ReactNode): ReacordInstance
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type { ComponentInteraction } from "../../internal/interaction"
|
|||||||
import type { MessageOptions } from "../../internal/message"
|
import type { MessageOptions } from "../../internal/message"
|
||||||
import { getNextActionRow } from "../../internal/message"
|
import { getNextActionRow } from "../../internal/message"
|
||||||
import { Node } from "../../internal/node.js"
|
import { Node } from "../../internal/node.js"
|
||||||
|
import type { ComponentEvent } from "../component-event"
|
||||||
|
|
||||||
export type ButtonProps = {
|
export type ButtonProps = {
|
||||||
label?: string
|
label?: string
|
||||||
@@ -14,7 +15,7 @@ export type ButtonProps = {
|
|||||||
onClick: (event: ButtonClickEvent) => void
|
onClick: (event: ButtonClickEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ButtonClickEvent = {}
|
export type ButtonClickEvent = ComponentEvent
|
||||||
|
|
||||||
export function Button(props: ButtonProps) {
|
export function Button(props: ButtonProps) {
|
||||||
return (
|
return (
|
||||||
@@ -41,7 +42,7 @@ class ButtonNode extends Node<ButtonProps> {
|
|||||||
interaction.type === "button" &&
|
interaction.type === "button" &&
|
||||||
interaction.customId === this.customId
|
interaction.customId === this.customId
|
||||||
) {
|
) {
|
||||||
this.props.onClick(interaction)
|
this.props.onClick(interaction.event)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ReacordElement } from "../../internal/element.js"
|
|||||||
import type { ComponentInteraction } from "../../internal/interaction"
|
import type { ComponentInteraction } from "../../internal/interaction"
|
||||||
import type { ActionRow, MessageOptions } from "../../internal/message"
|
import type { ActionRow, MessageOptions } from "../../internal/message"
|
||||||
import { Node } from "../../internal/node.js"
|
import { Node } from "../../internal/node.js"
|
||||||
|
import type { ComponentEvent } from "../component-event"
|
||||||
import { OptionNode } from "./option-node"
|
import { OptionNode } from "./option-node"
|
||||||
|
|
||||||
export type SelectProps = {
|
export type SelectProps = {
|
||||||
@@ -17,12 +18,12 @@ export type SelectProps = {
|
|||||||
minValues?: number
|
minValues?: number
|
||||||
maxValues?: number
|
maxValues?: number
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onSelect?: (event: SelectEvent) => void
|
onChange?: (event: SelectChangeEvent) => void
|
||||||
onSelectValue?: (value: string) => void
|
onChangeValue?: (value: string, event: SelectChangeEvent) => void
|
||||||
onSelectMultiple?: (values: string[]) => void
|
onChangeMultiple?: (values: string[], event: SelectChangeEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SelectEvent = {
|
export type SelectChangeEvent = ComponentEvent & {
|
||||||
values: string[]
|
values: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +53,9 @@ class SelectNode extends Node<SelectProps> {
|
|||||||
minValues = 0,
|
minValues = 0,
|
||||||
maxValues = 25,
|
maxValues = 25,
|
||||||
children,
|
children,
|
||||||
onSelect,
|
onChange,
|
||||||
onSelectValue,
|
onChangeValue,
|
||||||
onSelectMultiple,
|
onChangeMultiple,
|
||||||
...props
|
...props
|
||||||
} = this.props
|
} = this.props
|
||||||
|
|
||||||
@@ -72,18 +73,18 @@ class SelectNode extends Node<SelectProps> {
|
|||||||
override handleComponentInteraction(
|
override handleComponentInteraction(
|
||||||
interaction: ComponentInteraction,
|
interaction: ComponentInteraction,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (
|
const isSelectInteraction =
|
||||||
interaction.type === "select" &&
|
interaction.type === "select" &&
|
||||||
interaction.customId === this.customId &&
|
interaction.customId === this.customId &&
|
||||||
!this.props.disabled
|
!this.props.disabled
|
||||||
) {
|
|
||||||
this.props.onSelect?.({ values: interaction.values })
|
if (!isSelectInteraction) return false
|
||||||
this.props.onSelectMultiple?.(interaction.values)
|
|
||||||
if (interaction.values[0]) {
|
this.props.onChange?.(interaction.event)
|
||||||
this.props.onSelectValue?.(interaction.values[0])
|
this.props.onChangeMultiple?.(interaction.event.values, interaction.event)
|
||||||
|
if (interaction.event.values[0]) {
|
||||||
|
this.props.onChangeValue?.(interaction.event.values[0], interaction.event)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
7
library/core/instance.ts
Normal file
7
library/core/instance.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
export type ReacordInstance = {
|
||||||
|
render: (content: ReactNode) => void
|
||||||
|
deactivate: () => void
|
||||||
|
destroy: () => void
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
|
/* eslint-disable class-methods-use-this */
|
||||||
import type * as Discord from "discord.js"
|
import type * as Discord from "discord.js"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import type { Except } from "type-fest"
|
||||||
import { raise } from "../../helpers/raise"
|
import { raise } from "../../helpers/raise"
|
||||||
import { toUpper } from "../../helpers/to-upper"
|
import { toUpper } from "../../helpers/to-upper"
|
||||||
import type { ComponentInteraction } from "../internal/interaction"
|
import type { ComponentInteraction } from "../internal/interaction"
|
||||||
import type { Message, MessageOptions } from "../internal/message"
|
import type { Message, MessageOptions } from "../internal/message"
|
||||||
import { ChannelMessageRenderer } from "../internal/renderers/channel-message-renderer"
|
import { ChannelMessageRenderer } from "../internal/renderers/channel-message-renderer"
|
||||||
import { CommandReplyRenderer } from "../internal/renderers/command-reply-renderer"
|
import { InteractionReplyRenderer } from "../internal/renderers/interaction-reply-renderer"
|
||||||
import type { ReacordConfig, ReacordInstance } from "./reacord"
|
import type { ReacordInstance } from "./instance"
|
||||||
|
import type { ReacordConfig } from "./reacord"
|
||||||
import { Reacord } from "./reacord"
|
import { Reacord } from "./reacord"
|
||||||
|
|
||||||
export class ReacordDiscordJs extends Reacord {
|
export class ReacordDiscordJs extends Reacord {
|
||||||
@@ -15,7 +19,7 @@ export class ReacordDiscordJs extends Reacord {
|
|||||||
client.on("interactionCreate", (interaction) => {
|
client.on("interactionCreate", (interaction) => {
|
||||||
if (interaction.isMessageComponent()) {
|
if (interaction.isMessageComponent()) {
|
||||||
this.handleComponentInteraction(
|
this.handleComponentInteraction(
|
||||||
createReacordComponentInteraction(interaction),
|
this.createReacordComponentInteraction(interaction),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -26,7 +30,33 @@ export class ReacordDiscordJs extends Reacord {
|
|||||||
initialContent?: React.ReactNode,
|
initialContent?: React.ReactNode,
|
||||||
): ReacordInstance {
|
): ReacordInstance {
|
||||||
return this.createInstance(
|
return this.createInstance(
|
||||||
new ChannelMessageRenderer({
|
this.createChannelRenderer(channelId),
|
||||||
|
initialContent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override reply(
|
||||||
|
interaction: Discord.CommandInteraction,
|
||||||
|
initialContent?: React.ReactNode,
|
||||||
|
): ReacordInstance {
|
||||||
|
return this.createInstance(
|
||||||
|
this.createInteractionReplyRenderer(interaction),
|
||||||
|
initialContent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override ephemeralReply(
|
||||||
|
interaction: Discord.CommandInteraction,
|
||||||
|
initialContent?: React.ReactNode,
|
||||||
|
): ReacordInstance {
|
||||||
|
return this.createInstance(
|
||||||
|
this.createEphemeralInteractionReplyRenderer(interaction),
|
||||||
|
initialContent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createChannelRenderer(channelId: string) {
|
||||||
|
return new ChannelMessageRenderer({
|
||||||
send: async (options) => {
|
send: async (options) => {
|
||||||
const channel =
|
const channel =
|
||||||
this.client.channels.cache.get(channelId) ??
|
this.client.channels.cache.get(channelId) ??
|
||||||
@@ -40,20 +70,17 @@ export class ReacordDiscordJs extends Reacord {
|
|||||||
const message = await channel.send(getDiscordMessageOptions(options))
|
const message = await channel.send(getDiscordMessageOptions(options))
|
||||||
return createReacordMessage(message)
|
return createReacordMessage(message)
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
initialContent,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override reply(
|
private createInteractionReplyRenderer(
|
||||||
interaction: Discord.CommandInteraction,
|
interaction:
|
||||||
initialContent?: React.ReactNode,
|
| Discord.CommandInteraction
|
||||||
): ReacordInstance {
|
| Discord.MessageComponentInteraction,
|
||||||
return this.createInstance(
|
) {
|
||||||
new CommandReplyRenderer({
|
return new InteractionReplyRenderer({
|
||||||
type: "command",
|
type: "command",
|
||||||
id: interaction.id,
|
id: interaction.id,
|
||||||
channelId: interaction.channelId,
|
|
||||||
reply: async (options) => {
|
reply: async (options) => {
|
||||||
const message = await interaction.reply({
|
const message = await interaction.reply({
|
||||||
...getDiscordMessageOptions(options),
|
...getDiscordMessageOptions(options),
|
||||||
@@ -68,20 +95,17 @@ export class ReacordDiscordJs extends Reacord {
|
|||||||
})
|
})
|
||||||
return createReacordMessage(message as Discord.Message)
|
return createReacordMessage(message as Discord.Message)
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
initialContent,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override ephemeralReply(
|
private createEphemeralInteractionReplyRenderer(
|
||||||
interaction: Discord.CommandInteraction,
|
interaction:
|
||||||
initialContent?: React.ReactNode,
|
| Discord.CommandInteraction
|
||||||
): ReacordInstance {
|
| Discord.MessageComponentInteraction,
|
||||||
return this.createInstance(
|
) {
|
||||||
new CommandReplyRenderer({
|
return new InteractionReplyRenderer({
|
||||||
type: "command",
|
type: "command",
|
||||||
id: interaction.id,
|
id: interaction.id,
|
||||||
channelId: interaction.channelId,
|
|
||||||
reply: async (options) => {
|
reply: async (options) => {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
...getDiscordMessageOptions(options),
|
...getDiscordMessageOptions(options),
|
||||||
@@ -96,43 +120,110 @@ export class ReacordDiscordJs extends Reacord {
|
|||||||
})
|
})
|
||||||
return createEphemeralReacordMessage()
|
return createEphemeralReacordMessage()
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
initialContent,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function createReacordComponentInteraction(
|
private createReacordComponentInteraction(
|
||||||
interaction: Discord.MessageComponentInteraction,
|
interaction: Discord.MessageComponentInteraction,
|
||||||
): ComponentInteraction {
|
): ComponentInteraction {
|
||||||
if (interaction.isButton()) {
|
const baseProps: Except<ComponentInteraction, "type"> = {
|
||||||
return {
|
|
||||||
type: "button",
|
|
||||||
id: interaction.id,
|
id: interaction.id,
|
||||||
channelId: interaction.channelId,
|
|
||||||
customId: interaction.customId,
|
customId: interaction.customId,
|
||||||
update: async (options) => {
|
update: async (options: MessageOptions) => {
|
||||||
await interaction.update(getDiscordMessageOptions(options))
|
await interaction.update(getDiscordMessageOptions(options))
|
||||||
},
|
},
|
||||||
deferUpdate: () => interaction.deferUpdate(),
|
deferUpdate: async () => {
|
||||||
|
if (interaction.replied || interaction.deferred) return
|
||||||
|
await interaction.deferUpdate()
|
||||||
|
},
|
||||||
|
reply: async (options) => {
|
||||||
|
const message = await interaction.reply({
|
||||||
|
...getDiscordMessageOptions(options),
|
||||||
|
fetchReply: true,
|
||||||
|
})
|
||||||
|
return createReacordMessage(message as Discord.Message)
|
||||||
|
},
|
||||||
|
followUp: async (options) => {
|
||||||
|
const message = await interaction.followUp({
|
||||||
|
...getDiscordMessageOptions(options),
|
||||||
|
fetchReply: true,
|
||||||
|
})
|
||||||
|
return createReacordMessage(message as Discord.Message)
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
reply: (content?: ReactNode) =>
|
||||||
|
this.createInstance(
|
||||||
|
this.createInteractionReplyRenderer(interaction),
|
||||||
|
content,
|
||||||
|
),
|
||||||
|
|
||||||
|
ephemeralReply: (content: ReactNode) =>
|
||||||
|
this.createInstance(
|
||||||
|
this.createEphemeralInteractionReplyRenderer(interaction),
|
||||||
|
content,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isButton()) {
|
||||||
|
return {
|
||||||
|
...baseProps,
|
||||||
|
type: "button",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.isSelectMenu()) {
|
if (interaction.isSelectMenu()) {
|
||||||
return {
|
return {
|
||||||
|
...baseProps,
|
||||||
type: "select",
|
type: "select",
|
||||||
id: interaction.id,
|
event: {
|
||||||
channelId: interaction.channelId,
|
...baseProps.event,
|
||||||
customId: interaction.customId,
|
|
||||||
values: interaction.values,
|
values: interaction.values,
|
||||||
update: async (options) => {
|
|
||||||
await interaction.update(getDiscordMessageOptions(options))
|
|
||||||
},
|
},
|
||||||
deferUpdate: () => interaction.deferUpdate(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
raise(`Unsupported component interaction type: ${interaction.type}`)
|
raise(`Unsupported component interaction type: ${interaction.type}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReacordMessage(message: Discord.Message): Message {
|
||||||
|
return {
|
||||||
|
edit: async (options) => {
|
||||||
|
await message.edit(getDiscordMessageOptions(options))
|
||||||
|
},
|
||||||
|
disableComponents: async () => {
|
||||||
|
for (const actionRow of message.components) {
|
||||||
|
for (const component of actionRow.components) {
|
||||||
|
component.setDisabled(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await message.edit({
|
||||||
|
components: message.components,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
delete: async () => {
|
||||||
|
await message.delete()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEphemeralReacordMessage(): Message {
|
||||||
|
return {
|
||||||
|
edit: () => {
|
||||||
|
console.warn("Ephemeral messages can't be edited")
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
disableComponents: () => {
|
||||||
|
console.warn("Ephemeral messages can't be edited")
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
delete: () => {
|
||||||
|
console.warn("Ephemeral messages can't be deleted")
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: this could be a part of the core library,
|
// TODO: this could be a part of the core library,
|
||||||
@@ -182,42 +273,3 @@ function getDiscordMessageOptions(
|
|||||||
|
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
function createReacordMessage(message: Discord.Message): Message {
|
|
||||||
return {
|
|
||||||
edit: async (options) => {
|
|
||||||
await message.edit(getDiscordMessageOptions(options))
|
|
||||||
},
|
|
||||||
disableComponents: async () => {
|
|
||||||
for (const actionRow of message.components) {
|
|
||||||
for (const component of actionRow.components) {
|
|
||||||
component.setDisabled(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await message.edit({
|
|
||||||
components: message.components,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
delete: async () => {
|
|
||||||
await message.delete()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEphemeralReacordMessage(): Message {
|
|
||||||
return {
|
|
||||||
edit: () => {
|
|
||||||
console.warn("Ephemeral messages can't be edited")
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
disableComponents: () => {
|
|
||||||
console.warn("Ephemeral messages can't be edited")
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
delete: () => {
|
|
||||||
console.warn("Ephemeral messages can't be deleted")
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import type {
|
|||||||
MessageSelectOptions,
|
MessageSelectOptions,
|
||||||
} from "../internal/message"
|
} from "../internal/message"
|
||||||
import { ChannelMessageRenderer } from "../internal/renderers/channel-message-renderer"
|
import { ChannelMessageRenderer } from "../internal/renderers/channel-message-renderer"
|
||||||
import { CommandReplyRenderer } from "../internal/renderers/command-reply-renderer"
|
import { InteractionReplyRenderer } from "../internal/renderers/interaction-reply-renderer"
|
||||||
import type { ReacordInstance } from "./reacord"
|
import type { ButtonClickEvent } from "./components/button"
|
||||||
|
import type { SelectChangeEvent } from "./components/select"
|
||||||
|
import type { ReacordInstance } from "./instance"
|
||||||
import { Reacord } from "./reacord"
|
import { Reacord } from "./reacord"
|
||||||
|
|
||||||
const nextTickPromise = promisify(nextTick)
|
const nextTickPromise = promisify(nextTick)
|
||||||
@@ -46,7 +48,7 @@ export class ReacordTester extends Reacord {
|
|||||||
|
|
||||||
override reply(): ReacordInstance {
|
override reply(): ReacordInstance {
|
||||||
return this.createInstance(
|
return this.createInstance(
|
||||||
new CommandReplyRenderer(
|
new InteractionReplyRenderer(
|
||||||
new TestCommandInteraction(this.messageContainer),
|
new TestCommandInteraction(this.messageContainer),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -111,6 +113,10 @@ export class ReacordTester extends Reacord {
|
|||||||
raise(`Couldn't find select with placeholder "${placeholder}"`)
|
raise(`Couldn't find select with placeholder "${placeholder}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createMessage(options: MessageOptions) {
|
||||||
|
return new TestMessage(options, this.messageContainer)
|
||||||
|
}
|
||||||
|
|
||||||
private createButtonActions(
|
private createButtonActions(
|
||||||
button: MessageButtonOptions,
|
button: MessageButtonOptions,
|
||||||
message: TestMessage,
|
message: TestMessage,
|
||||||
@@ -118,7 +124,7 @@ export class ReacordTester extends Reacord {
|
|||||||
return {
|
return {
|
||||||
click: () => {
|
click: () => {
|
||||||
this.handleComponentInteraction(
|
this.handleComponentInteraction(
|
||||||
new TestButtonInteraction(button.customId, message),
|
new TestButtonInteraction(button.customId, message, this),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -131,7 +137,7 @@ export class ReacordTester extends Reacord {
|
|||||||
return {
|
return {
|
||||||
select: (...values: string[]) => {
|
select: (...values: string[]) => {
|
||||||
this.handleComponentInteraction(
|
this.handleComponentInteraction(
|
||||||
new TestSelectInteraction(component.customId, message, values),
|
new TestSelectInteraction(component.customId, message, values, this),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -189,13 +195,25 @@ class TestInteraction {
|
|||||||
readonly id = nanoid()
|
readonly id = nanoid()
|
||||||
readonly channelId = "test-channel-id"
|
readonly channelId = "test-channel-id"
|
||||||
|
|
||||||
constructor(readonly customId: string, readonly message: TestMessage) {}
|
constructor(
|
||||||
|
readonly customId: string,
|
||||||
|
readonly message: TestMessage,
|
||||||
|
private tester: ReacordTester,
|
||||||
|
) {}
|
||||||
|
|
||||||
async update(options: MessageOptions): Promise<void> {
|
async update(options: MessageOptions): Promise<void> {
|
||||||
this.message.options = options
|
this.message.options = options
|
||||||
}
|
}
|
||||||
|
|
||||||
async deferUpdate(): Promise<void> {}
|
async deferUpdate(): Promise<void> {}
|
||||||
|
|
||||||
|
async reply(messageOptions: MessageOptions): Promise<Message> {
|
||||||
|
return this.tester.createMessage(messageOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
async followUp(messageOptions: MessageOptions): Promise<Message> {
|
||||||
|
return this.tester.createMessage(messageOptions)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class TestButtonInteraction
|
class TestButtonInteraction
|
||||||
@@ -203,6 +221,12 @@ class TestButtonInteraction
|
|||||||
implements ButtonInteraction
|
implements ButtonInteraction
|
||||||
{
|
{
|
||||||
readonly type = "button"
|
readonly type = "button"
|
||||||
|
readonly event: ButtonClickEvent
|
||||||
|
|
||||||
|
constructor(customId: string, message: TestMessage, tester: ReacordTester) {
|
||||||
|
super(customId, message, tester)
|
||||||
|
this.event = new TestButtonClickEvent(tester)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class TestSelectInteraction
|
class TestSelectInteraction
|
||||||
@@ -210,13 +234,41 @@ class TestSelectInteraction
|
|||||||
implements SelectInteraction
|
implements SelectInteraction
|
||||||
{
|
{
|
||||||
readonly type = "select"
|
readonly type = "select"
|
||||||
|
readonly event: SelectChangeEvent
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
customId: string,
|
customId: string,
|
||||||
message: TestMessage,
|
message: TestMessage,
|
||||||
readonly values: string[],
|
readonly values: string[],
|
||||||
|
tester: ReacordTester,
|
||||||
) {
|
) {
|
||||||
super(customId, message)
|
super(customId, message, tester)
|
||||||
|
this.event = new TestSelectChangeEvent(values, tester)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestComponentEvent {
|
||||||
|
constructor(private tester: ReacordTester) {}
|
||||||
|
|
||||||
|
reply(content?: ReactNode): ReacordInstance {
|
||||||
|
return this.tester.reply()
|
||||||
|
}
|
||||||
|
|
||||||
|
ephemeralReply(content?: ReactNode): ReacordInstance {
|
||||||
|
return this.tester.ephemeralReply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestButtonClickEvent
|
||||||
|
extends TestComponentEvent
|
||||||
|
implements ButtonClickEvent {}
|
||||||
|
|
||||||
|
class TestSelectChangeEvent
|
||||||
|
extends TestComponentEvent
|
||||||
|
implements SelectChangeEvent
|
||||||
|
{
|
||||||
|
constructor(readonly values: string[], tester: ReacordTester) {
|
||||||
|
super(tester)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ReactNode } from "react"
|
|||||||
import type { ComponentInteraction } from "../internal/interaction"
|
import type { ComponentInteraction } from "../internal/interaction"
|
||||||
import { reconciler } from "../internal/reconciler.js"
|
import { reconciler } from "../internal/reconciler.js"
|
||||||
import type { Renderer } from "../internal/renderers/renderer"
|
import type { Renderer } from "../internal/renderers/renderer"
|
||||||
|
import type { ReacordInstance } from "./instance"
|
||||||
|
|
||||||
export type ReacordConfig = {
|
export type ReacordConfig = {
|
||||||
/**
|
/**
|
||||||
@@ -11,12 +12,6 @@ export type ReacordConfig = {
|
|||||||
maxInstances?: number
|
maxInstances?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReacordInstance = {
|
|
||||||
render: (content: ReactNode) => void
|
|
||||||
deactivate: () => void
|
|
||||||
destroy: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ComponentInteractionListener = (
|
export type ComponentInteractionListener = (
|
||||||
interaction: ComponentInteraction,
|
interaction: ComponentInteraction,
|
||||||
) => void
|
) => void
|
||||||
|
|||||||
@@ -1,32 +1,35 @@
|
|||||||
|
import type { ComponentEvent } from "../core/component-event"
|
||||||
|
import type { ButtonClickEvent, SelectChangeEvent } from "../main"
|
||||||
import type { Message, MessageOptions } from "./message"
|
import type { Message, MessageOptions } from "./message"
|
||||||
|
|
||||||
export type Interaction = CommandInteraction | ComponentInteraction
|
export type Interaction = CommandInteraction | ComponentInteraction
|
||||||
|
export type ComponentInteraction = ButtonInteraction | SelectInteraction
|
||||||
|
|
||||||
export type CommandInteraction = {
|
export type CommandInteraction = BaseInteraction<"command">
|
||||||
type: "command"
|
|
||||||
|
export type ButtonInteraction = BaseComponentInteraction<
|
||||||
|
"button",
|
||||||
|
ButtonClickEvent
|
||||||
|
>
|
||||||
|
|
||||||
|
export type SelectInteraction = BaseComponentInteraction<
|
||||||
|
"select",
|
||||||
|
SelectChangeEvent
|
||||||
|
>
|
||||||
|
|
||||||
|
export type BaseInteraction<Type extends string> = {
|
||||||
|
type: Type
|
||||||
id: string
|
id: string
|
||||||
channelId: string
|
|
||||||
reply(messageOptions: MessageOptions): Promise<Message>
|
reply(messageOptions: MessageOptions): Promise<Message>
|
||||||
followUp(messageOptions: MessageOptions): Promise<Message>
|
followUp(messageOptions: MessageOptions): Promise<Message>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ComponentInteraction = ButtonInteraction | SelectInteraction
|
export type BaseComponentInteraction<
|
||||||
|
Type extends string,
|
||||||
export type ButtonInteraction = {
|
Event extends ComponentEvent,
|
||||||
type: "button"
|
> = BaseInteraction<Type> & {
|
||||||
id: string
|
event: Event
|
||||||
channelId: string
|
|
||||||
customId: string
|
customId: string
|
||||||
update(options: MessageOptions): Promise<void>
|
update(options: MessageOptions): Promise<void>
|
||||||
deferUpdate(): Promise<void>
|
deferUpdate(): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SelectInteraction = {
|
|
||||||
type: "select"
|
|
||||||
id: string
|
|
||||||
channelId: string
|
|
||||||
customId: string
|
|
||||||
values: string[]
|
|
||||||
update(options: MessageOptions): Promise<void>
|
|
||||||
deferUpdate(): Promise<void>
|
|
||||||
}
|
|
||||||
|
|||||||
24
library/internal/limited-collection.ts
Normal file
24
library/internal/limited-collection.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export class LimitedCollection<T> {
|
||||||
|
private items: T[] = []
|
||||||
|
|
||||||
|
constructor(private readonly size: number) {}
|
||||||
|
|
||||||
|
add(item: T) {
|
||||||
|
if (this.items.length >= this.size) {
|
||||||
|
this.items.shift()
|
||||||
|
}
|
||||||
|
this.items.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
has(item: T) {
|
||||||
|
return this.items.includes(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
values(): readonly T[] {
|
||||||
|
return this.items
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.iterator]() {
|
||||||
|
return this.items[Symbol.iterator]()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CommandInteraction } from "../interaction"
|
import type { Interaction } from "../interaction"
|
||||||
import type { Message, MessageOptions } from "../message"
|
import type { Message, MessageOptions } from "../message"
|
||||||
import { Renderer } from "./renderer"
|
import { Renderer } from "./renderer"
|
||||||
|
|
||||||
@@ -6,8 +6,8 @@ import { Renderer } from "./renderer"
|
|||||||
// so we know whether to call reply() or followUp()
|
// so we know whether to call reply() or followUp()
|
||||||
const repliedInteractionIds = new Set<string>()
|
const repliedInteractionIds = new Set<string>()
|
||||||
|
|
||||||
export class CommandReplyRenderer extends Renderer {
|
export class InteractionReplyRenderer extends Renderer {
|
||||||
constructor(private interaction: CommandInteraction) {
|
constructor(private interaction: Interaction) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4,10 +4,10 @@ import { Container } from "../container.js"
|
|||||||
import type { ComponentInteraction } from "../interaction"
|
import type { ComponentInteraction } from "../interaction"
|
||||||
import type { Message, MessageOptions } from "../message"
|
import type { Message, MessageOptions } from "../message"
|
||||||
import type { Node } from "../node.js"
|
import type { Node } from "../node.js"
|
||||||
import { Timeout } from "../timeout"
|
|
||||||
|
|
||||||
type UpdatePayload =
|
type UpdatePayload =
|
||||||
| { action: "update" | "deactivate"; options: MessageOptions }
|
| { action: "update" | "deactivate"; options: MessageOptions }
|
||||||
|
| { action: "deferUpdate"; interaction: ComponentInteraction }
|
||||||
| { action: "destroy" }
|
| { action: "destroy" }
|
||||||
|
|
||||||
export abstract class Renderer {
|
export abstract class Renderer {
|
||||||
@@ -21,10 +21,6 @@ export abstract class Renderer {
|
|||||||
.pipe(concatMap((payload) => this.updateMessage(payload)))
|
.pipe(concatMap((payload) => this.updateMessage(payload)))
|
||||||
.subscribe({ error: console.error })
|
.subscribe({ error: console.error })
|
||||||
|
|
||||||
private deferUpdateTimeout = new Timeout(500, () => {
|
|
||||||
this.componentInteraction?.deferUpdate().catch(console.error)
|
|
||||||
})
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (!this.active) {
|
if (!this.active) {
|
||||||
console.warn("Attempted to update a deactivated message")
|
console.warn("Attempted to update a deactivated message")
|
||||||
@@ -52,7 +48,11 @@ export abstract class Renderer {
|
|||||||
|
|
||||||
handleComponentInteraction(interaction: ComponentInteraction) {
|
handleComponentInteraction(interaction: ComponentInteraction) {
|
||||||
this.componentInteraction = interaction
|
this.componentInteraction = interaction
|
||||||
this.deferUpdateTimeout.run()
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.updates.next({ action: "deferUpdate", interaction })
|
||||||
|
}, 500)
|
||||||
|
|
||||||
for (const node of this.nodes) {
|
for (const node of this.nodes) {
|
||||||
if (node.handleComponentInteraction(interaction)) {
|
if (node.handleComponentInteraction(interaction)) {
|
||||||
return true
|
return true
|
||||||
@@ -87,10 +87,14 @@ export abstract class Renderer {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.action === "deferUpdate") {
|
||||||
|
await payload.interaction.deferUpdate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (this.componentInteraction) {
|
if (this.componentInteraction) {
|
||||||
const promise = this.componentInteraction.update(payload.options)
|
const promise = this.componentInteraction.update(payload.options)
|
||||||
this.componentInteraction = undefined
|
this.componentInteraction = undefined
|
||||||
this.deferUpdateTimeout.cancel()
|
|
||||||
await promise
|
await promise
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from "./core/component-event"
|
||||||
export * from "./core/components/action-row"
|
export * from "./core/components/action-row"
|
||||||
export * from "./core/components/button"
|
export * from "./core/components/button"
|
||||||
export * from "./core/components/embed"
|
export * from "./core/components/embed"
|
||||||
@@ -10,6 +11,7 @@ export * from "./core/components/embed-title"
|
|||||||
export * from "./core/components/link"
|
export * from "./core/components/link"
|
||||||
export * from "./core/components/option"
|
export * from "./core/components/option"
|
||||||
export * from "./core/components/select"
|
export * from "./core/components/select"
|
||||||
|
export * from "./core/instance"
|
||||||
export * from "./core/reacord"
|
export * from "./core/reacord"
|
||||||
export * from "./core/reacord-discord-js"
|
export * from "./core/reacord-discord-js"
|
||||||
export * from "./core/reacord-tester"
|
export * from "./core/reacord-tester"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function FruitSelect() {
|
|||||||
<Select
|
<Select
|
||||||
placeholder="choose a fruit"
|
placeholder="choose a fruit"
|
||||||
value={value}
|
value={value}
|
||||||
onSelectValue={setValue}
|
onChangeValue={setValue}
|
||||||
>
|
>
|
||||||
<Option value="🍎" />
|
<Option value="🍎" />
|
||||||
<Option value="🍌" />
|
<Option value="🍌" />
|
||||||
|
|||||||
@@ -67,12 +67,16 @@ createCommandHandler(client, [
|
|||||||
run: (interaction) => {
|
run: (interaction) => {
|
||||||
reacord.reply(
|
reacord.reply(
|
||||||
interaction,
|
interaction,
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
label="public clic"
|
||||||
|
onClick={() => reacord.reply(interaction, "you clic")}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="clic"
|
label="clic"
|
||||||
onClick={() => {
|
onClick={(event) => event.ephemeralReply("you clic")}
|
||||||
reacord.ephemeralReply(interaction, "you clic")
|
/>
|
||||||
}}
|
</>,
|
||||||
/>,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
3
test/event-callbacks.test.tsx
Normal file
3
test/event-callbacks.test.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
test.todo("button onClick")
|
||||||
|
test.todo("select onChange")
|
||||||
|
export {}
|
||||||
@@ -14,8 +14,8 @@ test("single select", async () => {
|
|||||||
<Select
|
<Select
|
||||||
placeholder="choose one"
|
placeholder="choose one"
|
||||||
value={value}
|
value={value}
|
||||||
onSelect={onSelect}
|
onChange={onSelect}
|
||||||
onSelectValue={setValue}
|
onChangeValue={setValue}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Option value="1" />
|
<Option value="1" />
|
||||||
@@ -60,7 +60,9 @@ test("single select", async () => {
|
|||||||
|
|
||||||
tester.findSelectByPlaceholder("choose one").select("2")
|
tester.findSelectByPlaceholder("choose one").select("2")
|
||||||
await assertSelect(["2"])
|
await assertSelect(["2"])
|
||||||
expect(onSelect).toHaveBeenCalledWith({ values: ["2"] })
|
expect(onSelect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ values: ["2"] }),
|
||||||
|
)
|
||||||
|
|
||||||
tester.findButtonByLabel("disable").click()
|
tester.findButtonByLabel("disable").click()
|
||||||
await assertSelect(["2"], true)
|
await assertSelect(["2"], true)
|
||||||
@@ -81,8 +83,8 @@ test("multiple select", async () => {
|
|||||||
placeholder="select"
|
placeholder="select"
|
||||||
multiple
|
multiple
|
||||||
values={values}
|
values={values}
|
||||||
onSelect={onSelect}
|
onChange={onSelect}
|
||||||
onSelectMultiple={setValues}
|
onChangeMultiple={setValues}
|
||||||
>
|
>
|
||||||
<Option value="1">one</Option>
|
<Option value="1">one</Option>
|
||||||
<Option value="2">two</Option>
|
<Option value="2">two</Option>
|
||||||
@@ -124,19 +126,19 @@ test("multiple select", async () => {
|
|||||||
|
|
||||||
tester.findSelectByPlaceholder("select").select("1", "3")
|
tester.findSelectByPlaceholder("select").select("1", "3")
|
||||||
await assertSelect(expect.arrayContaining(["1", "3"]))
|
await assertSelect(expect.arrayContaining(["1", "3"]))
|
||||||
expect(onSelect).toHaveBeenCalledWith({
|
expect(onSelect).toHaveBeenCalledWith(
|
||||||
values: expect.arrayContaining(["1", "3"]),
|
expect.objectContaining({ values: expect.arrayContaining(["1", "3"]) }),
|
||||||
})
|
)
|
||||||
|
|
||||||
tester.findSelectByPlaceholder("select").select("2")
|
tester.findSelectByPlaceholder("select").select("2")
|
||||||
await assertSelect(expect.arrayContaining(["2"]))
|
await assertSelect(expect.arrayContaining(["2"]))
|
||||||
expect(onSelect).toHaveBeenCalledWith({
|
expect(onSelect).toHaveBeenCalledWith(
|
||||||
values: expect.arrayContaining(["2"]),
|
expect.objectContaining({ values: expect.arrayContaining(["2"]) }),
|
||||||
})
|
)
|
||||||
|
|
||||||
tester.findSelectByPlaceholder("select").select()
|
tester.findSelectByPlaceholder("select").select()
|
||||||
await assertSelect([])
|
await assertSelect([])
|
||||||
expect(onSelect).toHaveBeenCalledWith({ values: [] })
|
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ values: [] }))
|
||||||
})
|
})
|
||||||
|
|
||||||
test("optional onSelect + unknown value", async () => {
|
test("optional onSelect + unknown value", async () => {
|
||||||
|
|||||||
12
todo.md
12
todo.md
@@ -21,8 +21,14 @@
|
|||||||
- [x] select onChange
|
- [x] select onChange
|
||||||
- [x] action row
|
- [x] action row
|
||||||
- [x] button onClick
|
- [x] button onClick
|
||||||
- [ ] button click event
|
- component events
|
||||||
- [ ] select change event
|
- [x] reply / send functions
|
||||||
|
- [x] select values
|
||||||
|
- [ ] message.\*
|
||||||
|
- [ ] channel.\*
|
||||||
|
- [ ] guild.\*
|
||||||
|
- [ ] guild.member.\*
|
||||||
|
- [ ] user.\*
|
||||||
- [x] deactivate
|
- [x] deactivate
|
||||||
- [x] destroy
|
- [x] destroy
|
||||||
- [ ] docs
|
- [ ] docs
|
||||||
@@ -48,3 +54,5 @@
|
|||||||
- [x] single class/helper function for testing `ReacordTester`
|
- [x] single class/helper function for testing `ReacordTester`
|
||||||
- [ ] handle deletion outside of reacord
|
- [ ] handle deletion outside of reacord
|
||||||
- [ ] for more easily writing adapters, address discord API nuances at the reacord level instead of the adapter level. the goal being that adapters can just take the objects and send them to discord. probably make use of discord api types for this
|
- [ ] for more easily writing adapters, address discord API nuances at the reacord level instead of the adapter level. the goal being that adapters can just take the objects and send them to discord. probably make use of discord api types for this
|
||||||
|
- [ ] allow users to specify their own customId for components
|
||||||
|
- this could be an easy and intuitive way to make component interactions work over bot restarts... among other interesting things
|
||||||
|
|||||||
Reference in New Issue
Block a user