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])
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 true
}
return false
}
}

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,7 +30,33 @@ export class ReacordDiscordJs extends Reacord {
initialContent?: React.ReactNode,
): ReacordInstance {
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) => {
const channel =
this.client.channels.cache.get(channelId) ??
@@ -40,20 +70,17 @@ export class ReacordDiscordJs extends Reacord {
const message = await channel.send(getDiscordMessageOptions(options))
return createReacordMessage(message)
},
}),
initialContent,
)
})
}
override reply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
new CommandReplyRenderer({
private createInteractionReplyRenderer(
interaction:
| Discord.CommandInteraction
| Discord.MessageComponentInteraction,
) {
return new InteractionReplyRenderer({
type: "command",
id: interaction.id,
channelId: interaction.channelId,
reply: async (options) => {
const message = await interaction.reply({
...getDiscordMessageOptions(options),
@@ -68,20 +95,17 @@ export class ReacordDiscordJs extends Reacord {
})
return createReacordMessage(message as Discord.Message)
},
}),
initialContent,
)
})
}
override ephemeralReply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
new CommandReplyRenderer({
private createEphemeralInteractionReplyRenderer(
interaction:
| Discord.CommandInteraction
| Discord.MessageComponentInteraction,
) {
return new InteractionReplyRenderer({
type: "command",
id: interaction.id,
channelId: interaction.channelId,
reply: async (options) => {
await interaction.reply({
...getDiscordMessageOptions(options),
@@ -96,44 +120,111 @@ export class ReacordDiscordJs extends Reacord {
})
return createEphemeralReacordMessage()
},
}),
initialContent,
)
}
})
}
function createReacordComponentInteraction(
private createReacordComponentInteraction(
interaction: Discord.MessageComponentInteraction,
): ComponentInteraction {
if (interaction.isButton()) {
return {
type: "button",
const baseProps: Except<ComponentInteraction, "type"> = {
id: interaction.id,
channelId: interaction.channelId,
customId: interaction.customId,
update: async (options) => {
update: async (options: MessageOptions) => {
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()) {
return {
...baseProps,
type: "select",
id: interaction.id,
channelId: interaction.channelId,
customId: interaction.customId,
event: {
...baseProps.event,
values: interaction.values,
update: async (options) => {
await interaction.update(getDiscordMessageOptions(options))
},
deferUpdate: () => interaction.deferUpdate(),
}
}
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,
// and also handle some edge cases, e.g. empty messages
@@ -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"

View File

@@ -15,7 +15,7 @@ export function FruitSelect() {
<Select
placeholder="choose a fruit"
value={value}
onSelectValue={setValue}
onChangeValue={setValue}
>
<Option value="🍎" />
<Option value="🍌" />

View File

@@ -67,12 +67,16 @@ createCommandHandler(client, [
run: (interaction) => {
reacord.reply(
interaction,
<>
<Button
label="public clic"
onClick={() => reacord.reply(interaction, "you clic")}
/>
<Button
label="clic"
onClick={() => {
reacord.ephemeralReply(interaction, "you clic")
}}
/>,
onClick={(event) => event.ephemeralReply("you clic")}
/>
</>,
)
},
},

View File

@@ -0,0 +1,3 @@
test.todo("button onClick")
test.todo("select onChange")
export {}

View File

@@ -14,8 +14,8 @@ test("single select", async () => {
<Select
placeholder="choose one"
value={value}
onSelect={onSelect}
onSelectValue={setValue}
onChange={onSelect}
onChangeValue={setValue}
disabled={disabled}
>
<Option value="1" />
@@ -60,7 +60,9 @@ test("single select", async () => {
tester.findSelectByPlaceholder("choose one").select("2")
await assertSelect(["2"])
expect(onSelect).toHaveBeenCalledWith({ values: ["2"] })
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ values: ["2"] }),
)
tester.findButtonByLabel("disable").click()
await assertSelect(["2"], true)
@@ -81,8 +83,8 @@ test("multiple select", async () => {
placeholder="select"
multiple
values={values}
onSelect={onSelect}
onSelectMultiple={setValues}
onChange={onSelect}
onChangeMultiple={setValues}
>
<Option value="1">one</Option>
<Option value="2">two</Option>
@@ -124,19 +126,19 @@ test("multiple select", async () => {
tester.findSelectByPlaceholder("select").select("1", "3")
await assertSelect(expect.arrayContaining(["1", "3"]))
expect(onSelect).toHaveBeenCalledWith({
values: expect.arrayContaining(["1", "3"]),
})
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ values: expect.arrayContaining(["1", "3"]) }),
)
tester.findSelectByPlaceholder("select").select("2")
await assertSelect(expect.arrayContaining(["2"]))
expect(onSelect).toHaveBeenCalledWith({
values: expect.arrayContaining(["2"]),
})
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ values: expect.arrayContaining(["2"]) }),
)
tester.findSelectByPlaceholder("select").select()
await assertSelect([])
expect(onSelect).toHaveBeenCalledWith({ values: [] })
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ values: [] }))
})
test("optional onSelect + unknown value", async () => {

12
todo.md
View File

@@ -21,8 +21,14 @@
- [x] select onChange
- [x] action row
- [x] button onClick
- [ ] button click event
- [ ] select change event
- component events
- [x] reply / send functions
- [x] select values
- [ ] message.\*
- [ ] channel.\*
- [ ] guild.\*
- [ ] guild.member.\*
- [ ] user.\*
- [x] deactivate
- [x] destroy
- [ ] docs
@@ -48,3 +54,5 @@
- [x] single class/helper function for testing `ReacordTester`
- [ ] 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
- [ ] 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