component events

This commit is contained in:
MapleLeaf
2021-12-28 21:17:50 -06:00
parent 0f98d59618
commit b155cfd526
17 changed files with 370 additions and 204 deletions

View 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
}

View File

@@ -5,6 +5,7 @@ import type { ComponentInteraction } from "../../internal/interaction"
import type { MessageOptions } from "../../internal/message"
import { getNextActionRow } from "../../internal/message"
import { Node } from "../../internal/node.js"
import type { ComponentEvent } from "../component-event"
export type ButtonProps = {
label?: string
@@ -14,7 +15,7 @@ export type ButtonProps = {
onClick: (event: ButtonClickEvent) => void
}
export type ButtonClickEvent = {}
export type ButtonClickEvent = ComponentEvent
export function Button(props: ButtonProps) {
return (
@@ -41,7 +42,7 @@ class ButtonNode extends Node<ButtonProps> {
interaction.type === "button" &&
interaction.customId === this.customId
) {
this.props.onClick(interaction)
this.props.onClick(interaction.event)
return true
}
return false

View File

@@ -6,6 +6,7 @@ import { ReacordElement } from "../../internal/element.js"
import type { ComponentInteraction } from "../../internal/interaction"
import type { ActionRow, MessageOptions } from "../../internal/message"
import { Node } from "../../internal/node.js"
import type { ComponentEvent } from "../component-event"
import { OptionNode } from "./option-node"
export type SelectProps = {
@@ -17,12 +18,12 @@ export type SelectProps = {
minValues?: number
maxValues?: number
disabled?: boolean
onSelect?: (event: SelectEvent) => void
onSelectValue?: (value: string) => void
onSelectMultiple?: (values: string[]) => void
onChange?: (event: SelectChangeEvent) => void
onChangeValue?: (value: string, event: SelectChangeEvent) => void
onChangeMultiple?: (values: string[], event: SelectChangeEvent) => void
}
export type SelectEvent = {
export type SelectChangeEvent = ComponentEvent & {
values: string[]
}
@@ -52,9 +53,9 @@ class SelectNode extends Node<SelectProps> {
minValues = 0,
maxValues = 25,
children,
onSelect,
onSelectValue,
onSelectMultiple,
onChange,
onChangeValue,
onChangeMultiple,
...props
} = this.props
@@ -72,18 +73,18 @@ class SelectNode extends Node<SelectProps> {
override handleComponentInteraction(
interaction: ComponentInteraction,
): boolean {
if (
const isSelectInteraction =
interaction.type === "select" &&
interaction.customId === this.customId &&
!this.props.disabled
) {
this.props.onSelect?.({ values: interaction.values })
this.props.onSelectMultiple?.(interaction.values)
if (interaction.values[0]) {
this.props.onSelectValue?.(interaction.values[0])
}
return true
if (!isSelectInteraction) return false
this.props.onChange?.(interaction.event)
this.props.onChangeMultiple?.(interaction.event.values, interaction.event)
if (interaction.event.values[0]) {
this.props.onChangeValue?.(interaction.event.values[0], interaction.event)
}
return false
return true
}
}

7
library/core/instance.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { ReactNode } from "react"
export type ReacordInstance = {
render: (content: ReactNode) => void
deactivate: () => void
destroy: () => void
}

View File

@@ -1,11 +1,15 @@
/* eslint-disable class-methods-use-this */
import type * as Discord from "discord.js"
import type { ReactNode } from "react"
import type { Except } from "type-fest"
import { raise } from "../../helpers/raise"
import { toUpper } from "../../helpers/to-upper"
import type { ComponentInteraction } from "../internal/interaction"
import type { Message, MessageOptions } from "../internal/message"
import { ChannelMessageRenderer } from "../internal/renderers/channel-message-renderer"
import { CommandReplyRenderer } from "../internal/renderers/command-reply-renderer"
import type { ReacordConfig, ReacordInstance } from "./reacord"
import { InteractionReplyRenderer } from "../internal/renderers/interaction-reply-renderer"
import type { ReacordInstance } from "./instance"
import type { ReacordConfig } from "./reacord"
import { Reacord } from "./reacord"
export class ReacordDiscordJs extends Reacord {
@@ -15,7 +19,7 @@ export class ReacordDiscordJs extends Reacord {
client.on("interactionCreate", (interaction) => {
if (interaction.isMessageComponent()) {
this.handleComponentInteraction(
createReacordComponentInteraction(interaction),
this.createReacordComponentInteraction(interaction),
)
}
})
@@ -26,21 +30,7 @@ export class ReacordDiscordJs extends Reacord {
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
new ChannelMessageRenderer({
send: async (options) => {
const channel =
this.client.channels.cache.get(channelId) ??
(await this.client.channels.fetch(channelId)) ??
raise(`Channel ${channelId} not found`)
if (!channel.isText()) {
raise(`Channel ${channelId} is not a text channel`)
}
const message = await channel.send(getDiscordMessageOptions(options))
return createReacordMessage(message)
},
}),
this.createChannelRenderer(channelId),
initialContent,
)
}
@@ -50,25 +40,7 @@ export class ReacordDiscordJs extends Reacord {
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
new CommandReplyRenderer({
type: "command",
id: interaction.id,
channelId: interaction.channelId,
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)
},
}),
this.createInteractionReplyRenderer(interaction),
initialContent,
)
}
@@ -78,61 +50,180 @@ export class ReacordDiscordJs extends Reacord {
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
new CommandReplyRenderer({
type: "command",
id: interaction.id,
channelId: interaction.channelId,
reply: async (options) => {
await interaction.reply({
...getDiscordMessageOptions(options),
ephemeral: true,
})
return createEphemeralReacordMessage()
},
followUp: async (options) => {
await interaction.followUp({
...getDiscordMessageOptions(options),
ephemeral: true,
})
return createEphemeralReacordMessage()
},
}),
this.createEphemeralInteractionReplyRenderer(interaction),
initialContent,
)
}
private createChannelRenderer(channelId: string) {
return new ChannelMessageRenderer({
send: async (options) => {
const channel =
this.client.channels.cache.get(channelId) ??
(await this.client.channels.fetch(channelId)) ??
raise(`Channel ${channelId} not found`)
if (!channel.isText()) {
raise(`Channel ${channelId} is not a text channel`)
}
const message = await channel.send(getDiscordMessageOptions(options))
return createReacordMessage(message)
},
})
}
private createInteractionReplyRenderer(
interaction:
| Discord.CommandInteraction
| Discord.MessageComponentInteraction,
) {
return new InteractionReplyRenderer({
type: "command",
id: interaction.id,
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)
},
})
}
private createEphemeralInteractionReplyRenderer(
interaction:
| Discord.CommandInteraction
| Discord.MessageComponentInteraction,
) {
return new InteractionReplyRenderer({
type: "command",
id: interaction.id,
reply: async (options) => {
await interaction.reply({
...getDiscordMessageOptions(options),
ephemeral: true,
})
return createEphemeralReacordMessage()
},
followUp: async (options) => {
await interaction.followUp({
...getDiscordMessageOptions(options),
ephemeral: true,
})
return createEphemeralReacordMessage()
},
})
}
private createReacordComponentInteraction(
interaction: Discord.MessageComponentInteraction,
): ComponentInteraction {
const baseProps: Except<ComponentInteraction, "type"> = {
id: interaction.id,
customId: interaction.customId,
update: async (options: MessageOptions) => {
await interaction.update(getDiscordMessageOptions(options))
},
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()) {
return {
...baseProps,
type: "select",
event: {
...baseProps.event,
values: interaction.values,
},
}
}
raise(`Unsupported component interaction type: ${interaction.type}`)
}
}
function createReacordComponentInteraction(
interaction: Discord.MessageComponentInteraction,
): ComponentInteraction {
if (interaction.isButton()) {
return {
type: "button",
id: interaction.id,
channelId: interaction.channelId,
customId: interaction.customId,
update: async (options) => {
await interaction.update(getDiscordMessageOptions(options))
},
deferUpdate: () => interaction.deferUpdate(),
}
}
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)
}
}
if (interaction.isSelectMenu()) {
return {
type: "select",
id: interaction.id,
channelId: interaction.channelId,
customId: interaction.customId,
values: interaction.values,
update: async (options) => {
await interaction.update(getDiscordMessageOptions(options))
},
deferUpdate: () => interaction.deferUpdate(),
}
await message.edit({
components: message.components,
})
},
delete: async () => {
await message.delete()
},
}
}
raise(`Unsupported component interaction type: ${interaction.type}`)
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,
@@ -182,42 +273,3 @@ function getDiscordMessageOptions(
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()
},
}
}

View File

@@ -21,8 +21,10 @@ import type {
MessageSelectOptions,
} from "../internal/message"
import { ChannelMessageRenderer } from "../internal/renderers/channel-message-renderer"
import { CommandReplyRenderer } from "../internal/renderers/command-reply-renderer"
import type { ReacordInstance } from "./reacord"
import { InteractionReplyRenderer } from "../internal/renderers/interaction-reply-renderer"
import type { ButtonClickEvent } from "./components/button"
import type { SelectChangeEvent } from "./components/select"
import type { ReacordInstance } from "./instance"
import { Reacord } from "./reacord"
const nextTickPromise = promisify(nextTick)
@@ -46,7 +48,7 @@ export class ReacordTester extends Reacord {
override reply(): ReacordInstance {
return this.createInstance(
new CommandReplyRenderer(
new InteractionReplyRenderer(
new TestCommandInteraction(this.messageContainer),
),
)
@@ -111,6 +113,10 @@ export class ReacordTester extends Reacord {
raise(`Couldn't find select with placeholder "${placeholder}"`)
}
createMessage(options: MessageOptions) {
return new TestMessage(options, this.messageContainer)
}
private createButtonActions(
button: MessageButtonOptions,
message: TestMessage,
@@ -118,7 +124,7 @@ export class ReacordTester extends Reacord {
return {
click: () => {
this.handleComponentInteraction(
new TestButtonInteraction(button.customId, message),
new TestButtonInteraction(button.customId, message, this),
)
},
}
@@ -131,7 +137,7 @@ export class ReacordTester extends Reacord {
return {
select: (...values: string[]) => {
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 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> {
this.message.options = options
}
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
@@ -203,6 +221,12 @@ class TestButtonInteraction
implements ButtonInteraction
{
readonly type = "button"
readonly event: ButtonClickEvent
constructor(customId: string, message: TestMessage, tester: ReacordTester) {
super(customId, message, tester)
this.event = new TestButtonClickEvent(tester)
}
}
class TestSelectInteraction
@@ -210,13 +234,41 @@ class TestSelectInteraction
implements SelectInteraction
{
readonly type = "select"
readonly event: SelectChangeEvent
constructor(
customId: string,
message: TestMessage,
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)
}
}

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from "react"
import type { ComponentInteraction } from "../internal/interaction"
import { reconciler } from "../internal/reconciler.js"
import type { Renderer } from "../internal/renderers/renderer"
import type { ReacordInstance } from "./instance"
export type ReacordConfig = {
/**
@@ -11,12 +12,6 @@ export type ReacordConfig = {
maxInstances?: number
}
export type ReacordInstance = {
render: (content: ReactNode) => void
deactivate: () => void
destroy: () => void
}
export type ComponentInteractionListener = (
interaction: ComponentInteraction,
) => void

View File

@@ -1,32 +1,35 @@
import type { ComponentEvent } from "../core/component-event"
import type { ButtonClickEvent, SelectChangeEvent } from "../main"
import type { Message, MessageOptions } from "./message"
export type Interaction = CommandInteraction | ComponentInteraction
export type ComponentInteraction = ButtonInteraction | SelectInteraction
export type CommandInteraction = {
type: "command"
export type CommandInteraction = BaseInteraction<"command">
export type ButtonInteraction = BaseComponentInteraction<
"button",
ButtonClickEvent
>
export type SelectInteraction = BaseComponentInteraction<
"select",
SelectChangeEvent
>
export type BaseInteraction<Type extends string> = {
type: Type
id: string
channelId: string
reply(messageOptions: MessageOptions): Promise<Message>
followUp(messageOptions: MessageOptions): Promise<Message>
}
export type ComponentInteraction = ButtonInteraction | SelectInteraction
export type ButtonInteraction = {
type: "button"
id: string
channelId: string
export type BaseComponentInteraction<
Type extends string,
Event extends ComponentEvent,
> = BaseInteraction<Type> & {
event: Event
customId: string
update(options: MessageOptions): 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>
}

View 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]()
}
}

View File

@@ -1,4 +1,4 @@
import type { CommandInteraction } from "../interaction"
import type { Interaction } from "../interaction"
import type { Message, MessageOptions } from "../message"
import { Renderer } from "./renderer"
@@ -6,8 +6,8 @@ import { Renderer } from "./renderer"
// so we know whether to call reply() or followUp()
const repliedInteractionIds = new Set<string>()
export class CommandReplyRenderer extends Renderer {
constructor(private interaction: CommandInteraction) {
export class InteractionReplyRenderer extends Renderer {
constructor(private interaction: Interaction) {
super()
}

View File

@@ -4,10 +4,10 @@ import { Container } from "../container.js"
import type { ComponentInteraction } from "../interaction"
import type { Message, MessageOptions } from "../message"
import type { Node } from "../node.js"
import { Timeout } from "../timeout"
type UpdatePayload =
| { action: "update" | "deactivate"; options: MessageOptions }
| { action: "deferUpdate"; interaction: ComponentInteraction }
| { action: "destroy" }
export abstract class Renderer {
@@ -21,10 +21,6 @@ export abstract class Renderer {
.pipe(concatMap((payload) => this.updateMessage(payload)))
.subscribe({ error: console.error })
private deferUpdateTimeout = new Timeout(500, () => {
this.componentInteraction?.deferUpdate().catch(console.error)
})
render() {
if (!this.active) {
console.warn("Attempted to update a deactivated message")
@@ -52,7 +48,11 @@ export abstract class Renderer {
handleComponentInteraction(interaction: ComponentInteraction) {
this.componentInteraction = interaction
this.deferUpdateTimeout.run()
setTimeout(() => {
this.updates.next({ action: "deferUpdate", interaction })
}, 500)
for (const node of this.nodes) {
if (node.handleComponentInteraction(interaction)) {
return true
@@ -87,10 +87,14 @@ export abstract class Renderer {
return
}
if (payload.action === "deferUpdate") {
await payload.interaction.deferUpdate()
return
}
if (this.componentInteraction) {
const promise = this.componentInteraction.update(payload.options)
this.componentInteraction = undefined
this.deferUpdateTimeout.cancel()
await promise
return
}

View File

@@ -1,3 +1,4 @@
export * from "./core/component-event"
export * from "./core/components/action-row"
export * from "./core/components/button"
export * from "./core/components/embed"
@@ -10,6 +11,7 @@ export * from "./core/components/embed-title"
export * from "./core/components/link"
export * from "./core/components/option"
export * from "./core/components/select"
export * from "./core/instance"
export * from "./core/reacord"
export * from "./core/reacord-discord-js"
export * from "./core/reacord-tester"