From 589a59c5f627f1d38dfb60f67a5eafa40b1e0925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Susa=C3=B1a?= Date: Fri, 25 Apr 2025 00:10:09 -0400 Subject: [PATCH] feat: components V2 (#337) * feat: components v2 * fix: build * chore: apply formatting * refactor(components): some types * refactor(types): replace TopLevelComponents with APITopLevelComponent in REST * fix: unify components * refactor(TextDisplay): rename content method to setContent for clarity * refactor(builders): add missing builder from component * fix: touche * feat(webhook): webhook params for components v2 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/api/Routes/webhooks.ts | 5 +- src/builders/ActionRow.ts | 8 +- src/builders/Button.ts | 19 +- src/builders/Container.ts | 100 +++ src/builders/File.ts | 52 ++ src/builders/MediaGallery.ts | 112 ++++ src/builders/Section.ts | 55 ++ src/builders/Separator.ts | 54 ++ src/builders/TextDisplay.ts | 40 ++ src/builders/Thumbnail.ts | 62 ++ src/builders/index.ts | 41 +- src/builders/types.ts | 25 +- src/common/types/write.ts | 14 +- src/components/ActionRow.ts | 2 +- src/components/BaseComponent.ts | 53 +- src/components/Container.ts | 23 + src/components/File.ts | 16 + src/components/MediaGallery.ts | 12 + src/components/Section.ts | 23 + src/components/Separator.ts | 16 + src/components/TextDisplay.ts | 8 + src/components/Thumbnail.ts | 20 + src/components/index.ts | 55 +- src/structures/Interaction.ts | 4 +- src/structures/Message.ts | 9 +- src/structures/Webhook.ts | 4 +- src/structures/channels.ts | 6 +- .../_interactions/messageComponents.ts | 4 +- .../payloads/_interactions/modalSubmit.ts | 3 +- src/types/payloads/_interactions/responses.ts | 4 +- src/types/payloads/channel.ts | 420 +------------ src/types/payloads/components.ts | 594 ++++++++++++++++++ src/types/payloads/index.ts | 1 + src/types/rest/channel.ts | 7 +- src/types/rest/webhook.ts | 18 +- 35 files changed, 1410 insertions(+), 479 deletions(-) create mode 100644 src/builders/Container.ts create mode 100644 src/builders/File.ts create mode 100644 src/builders/MediaGallery.ts create mode 100644 src/builders/Section.ts create mode 100644 src/builders/Separator.ts create mode 100644 src/builders/TextDisplay.ts create mode 100644 src/builders/Thumbnail.ts create mode 100644 src/components/Container.ts create mode 100644 src/components/File.ts create mode 100644 src/components/MediaGallery.ts create mode 100644 src/components/Section.ts create mode 100644 src/components/Separator.ts create mode 100644 src/components/TextDisplay.ts create mode 100644 src/components/Thumbnail.ts create mode 100644 src/types/payloads/components.ts diff --git a/src/api/Routes/webhooks.ts b/src/api/Routes/webhooks.ts index 4121742..a02b264 100644 --- a/src/api/Routes/webhooks.ts +++ b/src/api/Routes/webhooks.ts @@ -9,6 +9,7 @@ import type { RESTPatchAPIWebhookResult, RESTPatchAPIWebhookWithTokenJSONBody, RESTPatchAPIWebhookWithTokenMessageJSONBody, + RESTPatchAPIWebhookWithTokenMessageQuery, RESTPatchAPIWebhookWithTokenMessageResult, RESTPatchAPIWebhookWithTokenResult, RESTPostAPIWebhookWithTokenGitHubQuery, @@ -33,7 +34,9 @@ export interface WebhookRoutes { token: string, ): { get(args?: RestArgumentsNoBody): Promise; - patch(args: RestArguments): Promise; + patch( + args: RestArguments, + ): Promise; delete(args?: RestArgumentsNoBody): Promise; post( args: RestArguments, diff --git a/src/builders/ActionRow.ts b/src/builders/ActionRow.ts index 90d3196..234b16b 100644 --- a/src/builders/ActionRow.ts +++ b/src/builders/ActionRow.ts @@ -7,13 +7,13 @@ import { } from '../types'; import { BaseComponentBuilder } from './Base'; import { fromComponent } from './index'; -import type { BuilderComponents, FixedComponents } from './types'; +import type { ActionBuilderComponents, FixedComponents } from './types'; /** * Represents an Action Row component in a message. * @template T - The type of components in the Action Row. */ -export class ActionRow extends BaseComponentBuilder< +export class ActionRow extends BaseComponentBuilder< APIActionRowComponent > { /** @@ -50,8 +50,8 @@ export class ActionRow extends BaseComponentBuilder * @example * actionRow.setComponents([buttonComponent1, buttonComponent2]); */ - setComponents(component: FixedComponents[]): this { - this.components = [...component]; + setComponents(...component: RestOrArray>): this { + this.components = component.flat() as FixedComponents[]; return this; } diff --git a/src/builders/Button.ts b/src/builders/Button.ts index 5e6b592..0a8d1c7 100644 --- a/src/builders/Button.ts +++ b/src/builders/Button.ts @@ -1,17 +1,14 @@ import { type EmojiResolvable, resolvePartialEmoji } from '../common'; import { type APIButtonComponent, type APIMessageComponentEmoji, type ButtonStyle, ComponentType } from '../types'; +import { BaseComponentBuilder } from './Base'; /** * Represents a button component. * @template Type - The type of the button component. */ -export class Button { - /** - * Creates a new Button instance. - * @param data - The initial data for the button. - */ - constructor(public data: Partial = {}) { - this.data.type = ComponentType.Button; +export class Button extends BaseComponentBuilder { + constructor(data: Partial = {}) { + super({ type: ComponentType.Button, ...data }); } /** @@ -76,12 +73,4 @@ export class Button { (this.data as Extract).sku_id = skuId; return this; } - - /** - * Converts the Button instance to its JSON representation. - * @returns The JSON representation of the Button instance. - */ - toJSON() { - return { ...this.data } as Partial; - } } diff --git a/src/builders/Container.ts b/src/builders/Container.ts new file mode 100644 index 0000000..20a9a59 --- /dev/null +++ b/src/builders/Container.ts @@ -0,0 +1,100 @@ +import { type ActionRow, fromComponent } from '.'; +import { type ColorResolvable, type RestOrArray, resolveColor } from '../common'; +import { type APIContainerComponent, ComponentType } from '../types'; +import { BaseComponentBuilder } from './Base'; +import type { File } from './File'; +import type { MediaGallery } from './MediaGallery'; +import type { Section } from './Section'; +import type { Separator } from './Separator'; +import type { TextDisplay } from './TextDisplay'; + +/** + * Represents the possible component types that can be added to a Container. + */ +export type ContainerBuilderComponents = ActionRow | TextDisplay | Section | MediaGallery | Separator | File; + +/** + * Represents a container component builder. + * Containers group other components together. + * @example + * ```ts + * const container = new Container() + * .addComponents( + * new TextDisplay('This is text inside a container!'), + * new ActionRow().addComponents(new Button().setLabel('Click me!')) + * ) + * .setColor('Blue'); + * ``` + */ +export class Container extends BaseComponentBuilder { + /** + * The components held within this container. + */ + components: ContainerBuilderComponents[]; + + /** + * Constructs a new Container. + * @param data Optional initial data for the container. + */ + constructor({ components, ...data }: Partial = {}) { + super({ ...data, type: ComponentType.Container }); + this.components = (components?.map(fromComponent) ?? []) as ContainerBuilderComponents[]; + } + + /** + * Adds components to the container. + * @param components The components to add. Can be a single component, an array of components, or multiple components as arguments. + * @returns The updated Container instance. + */ + addComponents(...components: RestOrArray) { + this.components = this.components.concat(components.flat()); + return this; + } + + /** + * Sets the components for the container, replacing any existing components. + * @param components The components to set. Can be a single component, an array of components, or multiple components as arguments. + * @returns The updated Container instance. + */ + setComponents(...components: RestOrArray) { + this.components = components.flat(); + return this; + } + + /** + * Sets whether the container's content should be visually marked as a spoiler. + * @param spoiler Whether the content is a spoiler (defaults to true). + * @returns The updated Container instance. + */ + setSpoiler(spoiler = true) { + this.data.spoiler = spoiler; + return this; + } + + /** + * Sets the accent color for the container. + * @param color The color resolvable (e.g., hex code, color name, integer). + * @returns The updated Container instance. + */ + setColor(color: ColorResolvable) { + this.data.accent_color = resolveColor(color); + return this; + } + + /** + * Sets the ID for the container. + * @param id The ID to set. + * @returns The updated Container instance. + */ + setId(id: number) { + this.data.id = id; + return this; + } + + toJSON() { + return { + ...this.data, + components: this.components.map(c => c.toJSON()), + } as APIContainerComponent; + } +} diff --git a/src/builders/File.ts b/src/builders/File.ts new file mode 100644 index 0000000..3c95c56 --- /dev/null +++ b/src/builders/File.ts @@ -0,0 +1,52 @@ +import { type APIFileComponent, ComponentType } from '../types'; +import { BaseComponentBuilder } from './Base'; + +/** + * Represents a file component builder. + * Used to display files within containers. + * @example + * ```ts + * const file = new File() + * .setMedia('https://example.com/image.png') + * .setSpoiler(); + * ``` + */ +export class File extends BaseComponentBuilder { + /** + * Constructs a new File component. + * @param data Optional initial data for the file component. + */ + constructor(data: Partial = {}) { + super({ type: ComponentType.File, ...data }); + } + + /** + * Sets the ID for the file component. + * @param id The ID to set. + * @returns The updated File instance. + */ + setId(id: number) { + this.data.id = id; + return this; + } + + /** + * Sets the media URL for the file. + * @param url The URL of the file to display. + * @returns The updated File instance. + */ + setMedia(url: string) { + this.data.file = { url }; + return this; + } + + /** + * Sets whether the file should be visually marked as a spoiler. + * @param spoiler Whether the file is a spoiler (defaults to true). + * @returns The updated File instance. + */ + setSpoiler(spoiler = true) { + this.data.spoiler = spoiler; + return this; + } +} diff --git a/src/builders/MediaGallery.ts b/src/builders/MediaGallery.ts new file mode 100644 index 0000000..c94ea54 --- /dev/null +++ b/src/builders/MediaGallery.ts @@ -0,0 +1,112 @@ +import type { RestOrArray } from '../common'; +import { type APIMediaGalleryComponent, type APIMediaGalleryItems, ComponentType } from '../types'; +import { BaseComponentBuilder } from './Base'; + +/** + * Represents a media gallery component builder. + * Used to display a collection of media items. + * @example + * ```ts + * const gallery = new MediaGallery() + * .addItems( + * new MediaGalleryItem().setMedia('https://example.com/image1.png').setDescription('Image 1'), + * new MediaGalleryItem().setMedia('https://example.com/image2.jpg').setSpoiler() + * ); + * ``` + */ +export class MediaGallery extends BaseComponentBuilder { + items: MediaGalleryItem[]; + /** + * Constructs a new MediaGallery. + * @param data Optional initial data for the media gallery. + */ + constructor({ items, ...data }: Partial = {}) { + super({ type: ComponentType.MediaGallery, ...data }); + this.items = (items?.map(i => new MediaGalleryItem(i)) ?? []) as MediaGalleryItem[]; + } + /** + * Sets the ID for the media gallery component. + * @param id The ID to set. + * @returns The updated MediaGallery instance. + */ + setId(id: number) { + this.data.id = id; + return this; + } + + /** + * Adds items to the media gallery. + * @param items The items to add. Can be a single item, an array of items, or multiple items as arguments. + * @returns The updated MediaGallery instance. + */ + addItems(...items: RestOrArray) { + this.items = this.items.concat(items.flat()); + return this; + } + + /** + * Sets the items for the media gallery, replacing any existing items. + * @param items The items to set. Can be a single item, an array of items, or multiple items as arguments. + * @returns The updated MediaGallery instance. + */ + setItems(...items: RestOrArray) { + this.items = items.flat(); + return this; + } + + toJSON() { + return { + ...this.data, + items: this.items.map(i => i.toJSON()), + } as APIMediaGalleryComponent; + } +} + +/** + * Represents an item within a MediaGallery. + */ +export class MediaGalleryItem { + /** + * Constructs a new MediaGalleryItem. + * @param data Optional initial data for the media gallery item. + */ + constructor(public data: Partial = {}) {} + + /** + * Sets the media URL for this gallery item. + * @param url The URL of the media. + * @returns The updated MediaGalleryItem instance. + */ + setMedia(url: string) { + this.data.media = { url }; + return this; + } + + /** + * Sets the description for this gallery item. + * @param desc The description text. + * @returns The updated MediaGalleryItem instance. + */ + setDescription(desc: string) { + this.data.description = desc; + return this; + } + + /** + * Sets whether this gallery item should be visually marked as a spoiler. + * @param spoiler Whether the item is a spoiler (defaults to true). + * @returns The updated MediaGalleryItem instance. + */ + setSpoiler(spoiler = true) { + this.data.spoiler = spoiler; + return this; + } + + /** + * Converts this MediaGalleryItem instance to its JSON representation. + * @returns The JSON representation of the item data. + */ + toJSON() { + return { ...this.data }; + } +} diff --git a/src/builders/Section.ts b/src/builders/Section.ts new file mode 100644 index 0000000..7c6bf85 --- /dev/null +++ b/src/builders/Section.ts @@ -0,0 +1,55 @@ +import { type Button, fromComponent } from '.'; +import type { RestOrArray } from '../common'; +import { type APISectionComponent, ComponentType } from '../types'; +import { BaseComponentBuilder } from './Base'; +import type { TextDisplay } from './TextDisplay'; +import type { Thumbnail } from './Thumbnail'; + +export class Section< + Ac extends Button | Thumbnail = Button | Thumbnail, +> extends BaseComponentBuilder { + components: TextDisplay[]; + accessory!: Ac; + constructor({ components, accessory, ...data }: Partial = {}) { + super({ type: ComponentType.Section, ...data }); + this.components = (components?.map(component => fromComponent(component)) ?? []) as TextDisplay[]; + if (accessory) this.accessory = fromComponent(accessory) as Ac; + } + + /** + * Adds components to this section. + * @param components The components to add + * @example section.addComponents(new TextDisplay().content('Hello')); + */ + addComponents(...components: RestOrArray) { + this.components = this.components.concat(components.flat()); + return this; + } + + /** + * Sets the components for this section. + * @param components The components to set + * @example section.setComponents(new TextDisplay().content('Hello')); + */ + setComponents(...components: RestOrArray) { + this.components = components.flat(); + return this; + } + + setAccessory(accessory: Ac) { + this.accessory = accessory; + return this; + } + + /** + * Converts this section to JSON. + * @returns The JSON representation of this section + */ + toJSON() { + return { + ...this.data, + components: this.components.map(component => component.toJSON()), + accessory: this.accessory.toJSON(), + } as APISectionComponent; + } +} diff --git a/src/builders/Separator.ts b/src/builders/Separator.ts new file mode 100644 index 0000000..2e19b9f --- /dev/null +++ b/src/builders/Separator.ts @@ -0,0 +1,54 @@ +import { type APISeparatorComponent, ComponentType, type Spacing } from '../types'; +import { BaseComponentBuilder } from './Base'; + +/** + * Represents a separator component builder. + * Used to add visual spacing or dividers between components. + * @example + * ```ts + * // A simple separator for spacing + * const spacingSeparator = new Separator().setSpacing(Spacing.Small); + * + * // A separator acting as a visual divider + * const dividerSeparator = new Separator().setDivider(true); + * ``` + */ +export class Separator extends BaseComponentBuilder { + /** + * Constructs a new Separator component. + * @param data Optional initial data for the separator component. + */ + constructor(data: Partial = {}) { + super({ type: ComponentType.Separator, ...data }); + } + + /** + * Sets the ID for the separator component. + * @param id The ID to set. + * @returns The updated Separator instance. + */ + setId(id: number) { + this.data.id = id; + return this; + } + + /** + * Sets whether this separator should act as a visual divider. + * @param divider Whether to render as a divider (defaults to false). + * @returns The updated Separator instance. + */ + setDivider(divider = false) { + this.data.divider = divider; + return this; + } + + /** + * Sets the amount of spacing this separator provides. + * @param spacing The desired spacing level ('None', 'Small', 'Medium', 'Large'). + * @returns The updated Separator instance. + */ + setSpacing(spacing: Spacing) { + this.data.spacing = spacing; + return this; + } +} diff --git a/src/builders/TextDisplay.ts b/src/builders/TextDisplay.ts new file mode 100644 index 0000000..93bf73b --- /dev/null +++ b/src/builders/TextDisplay.ts @@ -0,0 +1,40 @@ +import { type APITextDispalyComponent, ComponentType } from '../types'; +import { BaseComponentBuilder } from './Base'; + +/** + * Represents a text display component builder. + * Used to display simple text content. + * @example + * ```ts + * const text = new TextDisplay().content('Hello, world!'); + * ``` + */ +export class TextDisplay extends BaseComponentBuilder { + /** + * Constructs a new TextDisplay component. + * @param data Optional initial data for the text display component. + */ + constructor(data: Partial = {}) { + super({ type: ComponentType.TextDisplay, ...data }); + } + + /** + * Sets the ID for the text display component. + * @param id The ID to set. + * @returns The updated TextDisplay instance. + */ + setId(id: number) { + this.data.id = id; + return this; + } + + /** + * Sets the text content to display. + * @param content The text content. + * @returns The updated TextDisplay instance. + */ + setContent(content: string) { + this.data.content = content; + return this; + } +} diff --git a/src/builders/Thumbnail.ts b/src/builders/Thumbnail.ts new file mode 100644 index 0000000..328ce1a --- /dev/null +++ b/src/builders/Thumbnail.ts @@ -0,0 +1,62 @@ +import { type APIThumbnailComponent, ComponentType } from '../types'; +import { BaseComponentBuilder } from './Base'; + +/** + * Represents a thumbnail component builder. + * Used to display a small image preview, often alongside other content. + * @example + * ```ts + * const thumbnail = new Thumbnail() + * .setMedia('https://example.com/thumbnail.jpg') + * .setDescription('A cool thumbnail'); + * ``` + */ +export class Thumbnail extends BaseComponentBuilder { + /** + * Constructs a new Thumbnail component. + * @param data Optional initial data for the thumbnail component. + */ + constructor(data: Partial = {}) { + super({ type: ComponentType.Thumbnail, ...data }); + } + + /** + * Sets whether the thumbnail should be visually marked as a spoiler. + * @param spoiler Whether the thumbnail is a spoiler (defaults to true). + * @returns The updated Thumbnail instance. + */ + setSpoiler(spoiler = true) { + this.data.spoiler = spoiler; + return this; + } + + /** + * Sets the description for the thumbnail. + * @param description The description text. Can be undefined to remove the description. + * @returns The updated Thumbnail instance. + */ + setDescription(description: string | undefined) { + this.data.description = description; + return this; + } + + /** + * Sets the ID for the thumbnail component. + * @param id The ID to set. + * @returns The updated Thumbnail instance. + */ + setId(id: number) { + this.data.id = id; + return this; + } + + /** + * Sets the media URL for the thumbnail. + * @param url The URL of the image to display as a thumbnail. + * @returns The updated Thumbnail instance. + */ + setMedia(url: string) { + this.data.media = { url }; + return this; + } +} diff --git a/src/builders/index.ts b/src/builders/index.ts index 800aef3..42d7a6e 100644 --- a/src/builders/index.ts +++ b/src/builders/index.ts @@ -1,7 +1,11 @@ -import { type APIActionRowComponent, type APIActionRowComponentTypes, ComponentType } from '../types'; +import { type APIComponents, ComponentType } from '../types'; import { ActionRow } from './ActionRow'; import { Button } from './Button'; +import { Container } from './Container'; +import { File } from './File'; +import { MediaGallery } from './MediaGallery'; import { TextInput } from './Modal'; +import { Section } from './Section'; import { ChannelSelectMenu, MentionableSelectMenu, @@ -9,29 +13,32 @@ import { StringSelectMenu, UserSelectMenu, } from './SelectMenu'; +import { Separator } from './Separator'; +import { TextDisplay } from './TextDisplay'; +import { Thumbnail } from './Thumbnail'; import type { BuilderComponents } from './types'; export * from './ActionRow'; export * from './Attachment'; export * from './Base'; export * from './Button'; +export * from './Container'; export * from './Embed'; +export * from './File'; +export * from './MediaGallery'; export * from './Modal'; -export * from './SelectMenu'; export * from './Poll'; +export * from './Section'; +export * from './SelectMenu'; +export * from './Separator'; +export * from './TextDisplay'; +export * from './Thumbnail'; export * from './types'; -export function fromComponent( - data: - | BuilderComponents - | APIActionRowComponentTypes - | APIActionRowComponent - | ActionRow, -): BuilderComponents | ActionRow { +export function fromComponent(data: BuilderComponents | APIComponents): BuilderComponents { if ('toJSON' in data) { return data; } - switch (data.type) { case ComponentType.Button: return new Button(data); @@ -49,5 +56,19 @@ export function fromComponent( return new ChannelSelectMenu(data); case ComponentType.ActionRow: return new ActionRow(data); + case ComponentType.Section: + return new Section(data); + case ComponentType.TextDisplay: + return new TextDisplay(data); + case ComponentType.Thumbnail: + return new Thumbnail(data); + case ComponentType.Container: + return new Container(data); + case ComponentType.MediaGallery: + return new MediaGallery(data); + case ComponentType.Separator: + return new Separator(data); + case ComponentType.File: + return new File(data); } } diff --git a/src/builders/types.ts b/src/builders/types.ts index 18141d3..f69ddc0 100644 --- a/src/builders/types.ts +++ b/src/builders/types.ts @@ -3,9 +3,17 @@ import type { ModalSubmitInteraction, StringSelectMenuInteraction, } from '../structures/Interaction'; +import type { ActionRow } from './ActionRow'; import type { Button } from './Button'; +import type { Container } from './Container'; +import type { File } from './File'; +import type { MediaGallery } from './MediaGallery'; import type { TextInput } from './Modal'; +import type { Section } from './Section'; import type { BuilderSelectMenus } from './SelectMenu'; +import type { Separator } from './Separator'; +import type { TextDisplay } from './TextDisplay'; +import type { Thumbnail } from './Thumbnail'; export type ComponentCallback< T extends ComponentInteraction | StringSelectMenuInteraction = ComponentInteraction | StringSelectMenuInteraction, @@ -25,7 +33,22 @@ export type ButtonID = Omit; export type MessageBuilderComponents = FixedComponents