diff --git a/helpers/is-instance-of.ts b/helpers/is-instance-of.ts new file mode 100644 index 0000000..1fd16c5 --- /dev/null +++ b/helpers/is-instance-of.ts @@ -0,0 +1,7 @@ +/** + * for narrowing instance types with array.filter + */ +export const isInstanceOf = + (Constructor: new (...args: any[]) => T) => + (value: unknown): value is T => + value instanceof Constructor diff --git a/library/core/adapters/discord-js-adapter.ts b/library/core/adapters/discord-js-adapter.ts index 6af88e8..8897c6a 100644 --- a/library/core/adapters/discord-js-adapter.ts +++ b/library/core/adapters/discord-js-adapter.ts @@ -18,7 +18,7 @@ export class DiscordJsAdapter implements Adapter { listener: (interaction: ComponentInteraction) => void, ) { this.client.on("interactionCreate", (interaction) => { - if (interaction.isButton()) { + if (interaction.isMessageComponent()) { listener(createReacordComponentInteraction(interaction)) } }) @@ -56,15 +56,32 @@ export class DiscordJsAdapter implements Adapter { function createReacordComponentInteraction( interaction: Discord.MessageComponentInteraction, ): ComponentInteraction { - return { - type: "button", - id: interaction.id, - channelId: interaction.channelId, - customId: interaction.customId, - update: async (options) => { - await interaction.update(getDiscordMessageOptions(options)) - }, + if (interaction.isButton()) { + return { + type: "button", + id: interaction.id, + channelId: interaction.channelId, + customId: interaction.customId, + update: async (options) => { + await interaction.update(getDiscordMessageOptions(options)) + }, + } } + + 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)) + }, + } + } + + raise(`Unsupported component interaction type: ${interaction.type}`) } function createReacordMessage(message: Discord.Message): Message { @@ -94,19 +111,33 @@ function getDiscordMessageOptions( embeds: options.embeds, components: options.actionRows.map((row) => ({ type: "ACTION_ROW", - components: row.map((component) => { - if (component.type === "button") { - return { - type: "BUTTON", - customId: component.customId, - label: component.label ?? "", - style: toUpper(component.style ?? "secondary"), - disabled: component.disabled, - emoji: component.emoji, + components: row.map( + (component): Discord.MessageActionRowComponentOptions => { + if (component.type === "button") { + return { + type: "BUTTON", + customId: component.customId, + label: component.label ?? "", + style: toUpper(component.style ?? "secondary"), + disabled: component.disabled, + emoji: component.emoji, + } } - } - raise(`Unsupported component type: ${component.type}`) - }), + + if (component.type === "select") { + return { + ...component, + type: "SELECT_MENU", + options: component.options.map((option) => ({ + ...option, + default: component.values?.includes(option.value), + })), + } + } + + raise(`Unsupported component type: ${component.type}`) + }, + ), })), } } diff --git a/library/core/components/option-node.ts b/library/core/components/option-node.ts new file mode 100644 index 0000000..c913a0f --- /dev/null +++ b/library/core/components/option-node.ts @@ -0,0 +1,14 @@ +import type { MessageSelectOptionOptions } from "../../internal/message" +import { Node } from "../../internal/node" +import type { OptionProps } from "./option" + +export class OptionNode extends Node { + get options(): MessageSelectOptionOptions { + return { + label: this.props.children || this.props.label || this.props.value || "", + value: this.props.value, + description: this.props.description, + emoji: this.props.emoji, + } + } +} diff --git a/library/core/components/option.tsx b/library/core/components/option.tsx new file mode 100644 index 0000000..f4349b8 --- /dev/null +++ b/library/core/components/option.tsx @@ -0,0 +1,17 @@ +import React from "react" +import { ReacordElement } from "../../internal/element" +import { OptionNode } from "./option-node" + +export type OptionProps = { + label?: string + children?: string + value: string + description?: string + emoji?: string +} + +export function Option(props: OptionProps) { + return ( + new OptionNode(props)} /> + ) +} diff --git a/library/core/components/select.tsx b/library/core/components/select.tsx new file mode 100644 index 0000000..13ddbe2 --- /dev/null +++ b/library/core/components/select.tsx @@ -0,0 +1,89 @@ +import { nanoid } from "nanoid" +import type { ReactNode } from "react" +import React from "react" +import { isInstanceOf } from "../../../helpers/is-instance-of" +import { ReacordElement } from "../../internal/element.js" +import type { ComponentInteraction } from "../../internal/interaction" +import type { MessageOptions } from "../../internal/message" +import { getNextActionRow } from "../../internal/message" +import { Node } from "../../internal/node.js" +import { OptionNode } from "./option-node" + +export type SelectProps = { + children?: ReactNode + value?: string + values?: string[] + placeholder?: string + multiple?: boolean + minValues?: number + maxValues?: number + disabled?: boolean + onSelect?: (event: SelectEvent) => void + onSelectValue?: (value: string) => void + onSelectMultiple?: (values: string[]) => void +} + +export type SelectEvent = { + values: string[] +} + +export function Select(props: SelectProps) { + return ( + new SelectNode(props)}> + {props.children} + + ) +} + +class SelectNode extends Node { + readonly customId = nanoid() + + override modifyMessageOptions(message: MessageOptions): void { + const actionRow = getNextActionRow(message) + + const options = [...this.children] + .filter(isInstanceOf(OptionNode)) + .map((node) => node.options) + + const { + multiple, + value, + values, + minValues = 0, + maxValues = 25, + children, + onSelect, + onSelectValue, + onSelectMultiple, + ...props + } = this.props + + actionRow.push({ + ...props, + type: "select", + customId: this.customId, + options, + values: [...(values || []), ...(value ? [value] : [])], + minValues: multiple ? minValues : undefined, + maxValues: multiple ? Math.max(minValues, maxValues) : undefined, + }) + } + + override handleComponentInteraction( + interaction: ComponentInteraction, + ): boolean { + if ( + 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 + } + return false + } +} diff --git a/library/internal/interaction.ts b/library/internal/interaction.ts index ee9cd07..e5d9bba 100644 --- a/library/internal/interaction.ts +++ b/library/internal/interaction.ts @@ -25,5 +25,6 @@ export type SelectInteraction = { id: string channelId: string customId: string + values: string[] update(options: MessageOptions): Promise } diff --git a/library/internal/message.ts b/library/internal/message.ts index f1794b3..765d3d4 100644 --- a/library/internal/message.ts +++ b/library/internal/message.ts @@ -1,13 +1,18 @@ +import type { Except } from "type-fest" +import { last } from "../../helpers/last" import type { EmbedOptions } from "../core/components/embed-options" +import type { SelectProps } from "../core/components/select" export type MessageOptions = { content: string embeds: EmbedOptions[] - actionRows: Array< - Array - > + actionRows: ActionRow[] } +type ActionRow = Array< + MessageButtonOptions | MessageLinkOptions | MessageSelectOptions +> + export type MessageButtonOptions = { type: "button" customId: string @@ -25,12 +30,33 @@ export type MessageLinkOptions = { disabled?: boolean } -export type MessageSelectOptions = { +export type MessageSelectOptions = Except & { type: "select" customId: string + options: MessageSelectOptionOptions[] +} + +export type MessageSelectOptionOptions = { + label: string + value: string + description?: string + emoji?: string } export type Message = { edit(options: MessageOptions): Promise disableComponents(): Promise } + +export function getNextActionRow(options: MessageOptions): ActionRow { + let actionRow = last(options.actionRows) + if ( + actionRow == undefined || + actionRow.length >= 5 || + actionRow[0]?.type === "select" + ) { + actionRow = [] + options.actionRows.push(actionRow) + } + return actionRow +} diff --git a/library/main.ts b/library/main.ts index 7d4a1ee..f99a699 100644 --- a/library/main.ts +++ b/library/main.ts @@ -9,4 +9,6 @@ export * from "./core/components/embed-image" export * from "./core/components/embed-thumbnail" export * from "./core/components/embed-title" export * from "./core/components/link" +export * from "./core/components/option" +export * from "./core/components/select" export * from "./core/reacord" diff --git a/library/testing.ts b/library/testing.ts index b33da92..42d3c47 100644 --- a/library/testing.ts +++ b/library/testing.ts @@ -7,11 +7,13 @@ import type { ButtonInteraction, CommandInteraction, ComponentInteraction, + SelectInteraction, } from "./internal/interaction" import type { Message, MessageButtonOptions, MessageOptions, + MessageSelectOptions, } from "./internal/message" export class TestAdapter implements Adapter { @@ -44,6 +46,20 @@ export class TestAdapter implements Adapter { raise(`Couldn't find button with label "${label}"`) } + findSelectByPlaceholder(placeholder: string) { + for (const message of this.messages) { + for (const component of message.options.actionRows.flat()) { + if ( + component.type === "select" && + component.placeholder === placeholder + ) { + return this.createSelectActions(component, message) + } + } + } + raise(`Couldn't find select with placeholder "${placeholder}"`) + } + private createButtonActions( button: MessageButtonOptions, message: TestMessage, @@ -56,6 +72,19 @@ export class TestAdapter implements Adapter { }, } } + + private createSelectActions( + component: MessageSelectOptions, + message: TestMessage, + ) { + return { + select: (...values: string[]) => { + this.componentInteractionListener( + new TestSelectInteraction(component.customId, message, values), + ) + }, + } + } } export class TestMessage implements Message { @@ -109,3 +138,19 @@ export class TestButtonInteraction implements ButtonInteraction { this.message.options = options } } + +export class TestSelectInteraction implements SelectInteraction { + readonly type = "select" + readonly id = nanoid() + readonly channelId = "test-channel-id" + + constructor( + readonly customId: string, + readonly message: TestMessage, + readonly values: string[], + ) {} + + async update(options: MessageOptions): Promise { + this.message.options = options + } +} diff --git a/notes.md b/notes.md index 287860d..ec7a46d 100644 --- a/notes.md +++ b/notes.md @@ -17,10 +17,10 @@ - message components - [x] buttons - [x] links - - [ ] select + - [x] select + - [x] select onChange - [ ] action row - [x] button onClick - - [ ] select onChange - [x] deactivate - [ ] destroy - [ ] docs @@ -37,3 +37,5 @@ - [ ] `useReactions` - [ ] max instance count per guild - [ ] max instance count per channel +- [ ] uncontrolled select +- [ ] single class/helper function for testing `ReacordTester` diff --git a/playground/fruit-select.tsx b/playground/fruit-select.tsx new file mode 100644 index 0000000..46607ab --- /dev/null +++ b/playground/fruit-select.tsx @@ -0,0 +1,31 @@ +import React, { useState } from "react" +import { Button, Option, Select } from "../library/main" + +export function FruitSelect() { + const [value, setValue] = useState() + const [finalChoice, setFinalChoice] = useState() + + if (finalChoice) { + return <>you chose {finalChoice} + } + + return ( + <> + {"_ _"} + +