diff --git a/packages/builders/__tests__/components/actionRow.test.ts b/packages/builders/__tests__/components/actionRow.test.ts index b9f63b501529..9e1244fdec83 100644 --- a/packages/builders/__tests__/components/actionRow.test.ts +++ b/packages/builders/__tests__/components/actionRow.test.ts @@ -2,7 +2,7 @@ import { ButtonStyle, ComponentType, type APIActionRowComponent, - type APIMessageActionRowComponent, + type APIComponentInMessageActionRow, } from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; import { @@ -13,7 +13,7 @@ import { StringSelectMenuOptionBuilder, } from '../../src/index.js'; -const rowWithButtonData: APIActionRowComponent = { +const rowWithButtonData: APIActionRowComponent = { type: ComponentType.ActionRow, components: [ { @@ -25,7 +25,7 @@ const rowWithButtonData: APIActionRowComponent = { ], }; -const rowWithSelectMenuData: APIActionRowComponent = { +const rowWithSelectMenuData: APIActionRowComponent = { type: ComponentType.ActionRow, components: [ { @@ -57,7 +57,7 @@ describe('Action Row Components', () => { }); test('GIVEN valid JSON input THEN valid JSON output is given', () => { - const actionRowData: APIActionRowComponent = { + const actionRowData: APIActionRowComponent = { type: ComponentType.ActionRow, components: [ { @@ -92,7 +92,7 @@ describe('Action Row Components', () => { }); test('GIVEN valid builder options THEN valid JSON output is given', () => { - const rowWithButtonData: APIActionRowComponent = { + const rowWithButtonData: APIActionRowComponent = { type: ComponentType.ActionRow, components: [ { @@ -104,7 +104,7 @@ describe('Action Row Components', () => { ], }; - const rowWithSelectMenuData: APIActionRowComponent = { + const rowWithSelectMenuData: APIActionRowComponent = { type: ComponentType.ActionRow, components: [ { diff --git a/packages/builders/__tests__/components/components.test.ts b/packages/builders/__tests__/components/components.test.ts index fa0bd4607f65..ea53b9eeb8ee 100644 --- a/packages/builders/__tests__/components/components.test.ts +++ b/packages/builders/__tests__/components/components.test.ts @@ -3,7 +3,7 @@ import { ComponentType, TextInputStyle, type APIButtonComponent, - type APIMessageActionRowComponent, + type APIComponentInMessageActionRow, type APISelectMenuComponent, type APITextInputComponent, type APIActionRowComponent, @@ -27,7 +27,7 @@ describe('createComponentBuilder', () => { ); test('GIVEN an action row component THEN returns a ActionRowBuilder', () => { - const actionRow: APIActionRowComponent = { + const actionRow: APIActionRowComponent = { components: [], type: ComponentType.ActionRow, }; diff --git a/packages/builders/__tests__/components/v2/container.test.ts b/packages/builders/__tests__/components/v2/container.test.ts new file mode 100644 index 000000000000..b01d2fd92a82 --- /dev/null +++ b/packages/builders/__tests__/components/v2/container.test.ts @@ -0,0 +1,248 @@ +import { type APIContainerComponent, ComponentType, SeparatorSpacingSize } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { ButtonBuilder } from '../../../dist/index.mjs'; +import { ActionRowBuilder } from '../../../src/components/ActionRow.js'; +import { createComponentBuilder } from '../../../src/components/Components.js'; +import { ContainerBuilder } from '../../../src/components/v2/Container.js'; +import { FileBuilder } from '../../../src/components/v2/File.js'; +import { MediaGalleryBuilder } from '../../../src/components/v2/MediaGallery.js'; +import { SectionBuilder } from '../../../src/components/v2/Section.js'; +import { SeparatorBuilder } from '../../../src/components/v2/Separator.js'; +import { TextDisplayBuilder } from '../../../src/components/v2/TextDisplay.js'; + +const containerWithTextDisplay: APIContainerComponent = { + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + id: 123, + }, + ], +}; + +const containerWithSeparatorData: APIContainerComponent = { + type: ComponentType.Container, + components: [ + { + type: ComponentType.Separator, + id: 1_234, + spacing: SeparatorSpacingSize.Small, + divider: false, + }, + ], + accent_color: 0x00ff00, +}; + +const containerWithSeparatorDataNoColor: APIContainerComponent = { + type: ComponentType.Container, + components: [ + { + type: ComponentType.Separator, + id: 1_234, + spacing: SeparatorSpacingSize.Small, + divider: false, + }, + ], +}; + +describe('Container Components', () => { + describe('Assertion Tests', () => { + test('GIVEN valid components THEN do not throw', () => { + expect(() => + new ContainerBuilder().addActionRowComponents( + new ActionRowBuilder().addComponents(new ButtonBuilder()), + ), + ).not.toThrowError(); + expect(() => new ContainerBuilder().addFileComponents(new FileBuilder())).not.toThrowError(); + expect(() => new ContainerBuilder().addMediaGalleryComponents(new MediaGalleryBuilder())).not.toThrowError(); + expect(() => new ContainerBuilder().addSectionComponents(new SectionBuilder())).not.toThrowError(); + expect(() => new ContainerBuilder().addSeparatorComponents(new SeparatorBuilder())).not.toThrowError(); + expect(() => new ContainerBuilder().addTextDisplayComponents(new TextDisplayBuilder())).not.toThrowError(); + expect(() => new ContainerBuilder().spliceComponents(0, 0, new SeparatorBuilder())).not.toThrowError(); + expect(() => new ContainerBuilder().addSeparatorComponents([new SeparatorBuilder()])).not.toThrowError(); + expect(() => + new ContainerBuilder().spliceComponents(0, 0, [{ type: ComponentType.Separator }]), + ).not.toThrowError(); + }); + + test('GIVEN valid JSON input THEN valid JSON output is given', () => { + const containerData: APIContainerComponent = { + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + id: 3, + }, + { + type: ComponentType.Separator, + spacing: SeparatorSpacingSize.Large, + divider: true, + id: 4, + }, + { + type: ComponentType.File, + file: { + url: 'attachment://file.png', + }, + spoiler: false, + }, + ], + accent_color: 0xff00ff, + spoiler: true, + }; + + expect(new ContainerBuilder(containerData).toJSON()).toEqual(containerData); + expect(() => createComponentBuilder({ type: ComponentType.Container, components: [] })).not.toThrowError(); + }); + + test('GIVEN valid builder options THEN valid JSON output is given', () => { + const containerWithTextDisplay: APIContainerComponent = { + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + id: 123, + }, + ], + }; + + const containerWithSeparatorData: APIContainerComponent = { + type: ComponentType.Container, + components: [ + { + type: ComponentType.Separator, + id: 1_234, + spacing: SeparatorSpacingSize.Small, + divider: false, + }, + ], + accent_color: 0x00ff00, + }; + + expect(new ContainerBuilder(containerWithTextDisplay).toJSON()).toEqual(containerWithTextDisplay); + expect(new ContainerBuilder(containerWithSeparatorData).toJSON()).toEqual(containerWithSeparatorData); + expect(() => createComponentBuilder({ type: ComponentType.Container, components: [] })).not.toThrowError(); + }); + + test('GIVEN valid builder options THEN valid JSON output is given 2', () => { + const textDisplay = new TextDisplayBuilder().setContent('test').setId(123); + const separator = new SeparatorBuilder().setId(1_234).setSpacing(SeparatorSpacingSize.Small).setDivider(false); + + expect(new ContainerBuilder().addTextDisplayComponents(textDisplay).toJSON()).toEqual(containerWithTextDisplay); + expect(new ContainerBuilder().addSeparatorComponents(separator).toJSON()).toEqual( + containerWithSeparatorDataNoColor, + ); + expect(new ContainerBuilder().addTextDisplayComponents([textDisplay]).toJSON()).toEqual(containerWithTextDisplay); + expect(new ContainerBuilder().addSeparatorComponents([separator]).toJSON()).toEqual( + containerWithSeparatorDataNoColor, + ); + }); + + test('GIVEN valid accent color THEN valid JSON output is given', () => { + expect( + new ContainerBuilder({ + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + }) + .setAccentColor([255, 0, 255]) + .toJSON(), + ).toEqual({ + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accent_color: 0xff00ff, + }); + expect( + new ContainerBuilder({ + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + }) + .setAccentColor(0xff00ff) + .toJSON(), + ).toEqual({ + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accent_color: 0xff00ff, + }); + expect( + new ContainerBuilder({ + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + }) + .setAccentColor([255, 0, 255]) + .clearAccentColor() + .toJSON(), + ).toEqual({ + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + }); + expect(new ContainerBuilder(containerWithSeparatorData).clearAccentColor().toJSON()).toEqual( + containerWithSeparatorDataNoColor, + ); + }); + + test('GIVEN valid method parameters THEN valid JSON is given', () => { + expect( + new ContainerBuilder() + .addTextDisplayComponents(new TextDisplayBuilder().setId(3).clearId().setContent('test')) + .setSpoiler() + .toJSON(), + ).toEqual({ + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + spoiler: true, + }); + expect( + new ContainerBuilder() + .addTextDisplayComponents({ type: ComponentType.TextDisplay, content: 'test' }) + .setSpoiler(false) + .setId(5) + .toJSON(), + ).toEqual({ + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + spoiler: false, + id: 5, + }); + }); + }); +}); diff --git a/packages/builders/__tests__/components/v2/file.test.ts b/packages/builders/__tests__/components/v2/file.test.ts new file mode 100644 index 000000000000..96bd7c250e6e --- /dev/null +++ b/packages/builders/__tests__/components/v2/file.test.ts @@ -0,0 +1,44 @@ +import { ComponentType } from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { FileBuilder } from '../../../src/components/v2/File'; + +const dummy = { + type: ComponentType.File as const, + file: { url: 'attachment://owo.png' }, +}; + +describe('File', () => { + describe('File url', () => { + test('GIVEN a file with a pre-defined url THEN return valid toJSON data', () => { + const file = new FileBuilder({ file: { url: 'attachment://owo.png' } }); + expect(file.toJSON()).toEqual({ ...dummy, file: { url: 'attachment://owo.png' } }); + }); + + test('GIVEN a file using File#setURL THEN return valid toJSON data', () => { + const file = new FileBuilder(); + file.setURL('attachment://uwu.png'); + + expect(file.toJSON()).toEqual({ ...dummy, file: { url: 'attachment://uwu.png' } }); + }); + + test('GIVEN a file with an invalid url THEN throws error', () => { + const file = new FileBuilder(); + + expect(() => file.setURL('https://google.com')).toThrowError(); + }); + }); + + describe('File spoiler', () => { + test('GIVEN a file with a pre-defined spoiler status THEN return valid toJSON data', () => { + const file = new FileBuilder({ ...dummy, spoiler: true }); + expect(file.toJSON()).toEqual({ ...dummy, spoiler: true }); + }); + + test('GIVEN a file using File#setSpoiler THEN return valid toJSON data', () => { + const file = new FileBuilder({ ...dummy }); + file.setSpoiler(false); + + expect(file.toJSON()).toEqual({ ...dummy, spoiler: false }); + }); + }); +}); diff --git a/packages/builders/__tests__/components/v2/mediagallery.test.ts b/packages/builders/__tests__/components/v2/mediagallery.test.ts new file mode 100644 index 000000000000..965059dc0ff1 --- /dev/null +++ b/packages/builders/__tests__/components/v2/mediagallery.test.ts @@ -0,0 +1,150 @@ +import { type APIMediaGalleryItem, type APIMediaGalleryComponent, ComponentType } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { createComponentBuilder } from '../../../src/components/Components.js'; +import { MediaGalleryBuilder } from '../../../src/components/v2/MediaGallery.js'; +import { MediaGalleryItemBuilder } from '../../../src/components/v2/MediaGalleryItem.js'; + +const galleryHttpsDisplay: APIMediaGalleryComponent = { + type: ComponentType.MediaGallery, + items: [ + { + description: 'test', + spoiler: false, + media: { url: 'https://discord.com/logo.png' }, + }, + ], +}; + +const galleryAttachmentData: APIMediaGalleryComponent = { + type: ComponentType.MediaGallery, + items: [ + { + media: { url: 'attachment://file.png' }, + }, + ], + id: 123, +}; + +describe('Media Gallery Components', () => { + describe('Assertion Tests', () => { + test('GIVEN an empty media gallery THEN throws error', () => { + const gallery = new MediaGalleryBuilder(); + expect(() => gallery.toJSON()).toThrow(); + }); + + test('GIVEN valid items THEN do not throw', () => { + expect(() => new MediaGalleryBuilder().addItems(new MediaGalleryItemBuilder())).not.toThrowError(); + expect(() => new MediaGalleryBuilder().spliceItems(0, 0, new MediaGalleryItemBuilder())).not.toThrowError(); + expect(() => new MediaGalleryBuilder().addItems([new MediaGalleryItemBuilder()])).not.toThrowError(); + expect(() => new MediaGalleryBuilder().spliceItems(0, 0, [new MediaGalleryItemBuilder()])).not.toThrowError(); + }); + + test('GIVEN valid JSON input THEN valid JSON output is given', () => { + const mediaGalleryData: APIMediaGalleryComponent = { + type: ComponentType.MediaGallery, + items: [ + { + media: { url: 'attachment://file.png' }, + description: 'test', + spoiler: false, + }, + { + media: { url: 'https://discord.js.org/logo.jpg' }, + spoiler: true, + }, + ], + id: 1_234, + }; + + expect(new MediaGalleryBuilder(mediaGalleryData).toJSON()).toEqual(mediaGalleryData); + expect(() => createComponentBuilder({ type: ComponentType.MediaGallery, items: [] })).not.toThrowError(); + }); + + test('GIVEN valid builder options THEN valid JSON output is given', () => { + const galleryHttpsDisplay: APIMediaGalleryComponent = { + type: ComponentType.MediaGallery, + items: [ + { + description: 'test', + spoiler: false, + media: { url: 'https://discord.com/logo.png' }, + }, + ], + }; + + const galleryAttachmentData: APIMediaGalleryComponent = { + type: ComponentType.MediaGallery, + items: [ + { + media: { url: 'attachment://file.png' }, + }, + ], + id: 123, + }; + + expect(new MediaGalleryBuilder(galleryHttpsDisplay).toJSON()).toEqual(galleryHttpsDisplay); + expect(new MediaGalleryBuilder(galleryAttachmentData).toJSON()).toEqual(galleryAttachmentData); + expect(() => createComponentBuilder({ type: ComponentType.MediaGallery, items: [] })).not.toThrowError(); + }); + + test('GIVEN valid builder options THEN valid JSON output is given 2', () => { + const item1 = new MediaGalleryItemBuilder() + .setDescription('test') + .setSpoiler(false) + .setURL('https://discord.com/logo.png'); + const item2 = new MediaGalleryItemBuilder().setURL('attachment://file.png'); + + expect(new MediaGalleryBuilder().addItems(item1).toJSON()).toEqual(galleryHttpsDisplay); + expect(new MediaGalleryBuilder().addItems(item2).setId(123).toJSON()).toEqual(galleryAttachmentData); + expect(new MediaGalleryBuilder().addItems([item1]).toJSON()).toEqual(galleryHttpsDisplay); + expect(new MediaGalleryBuilder().addItems([item2]).setId(123).toJSON()).toEqual(galleryAttachmentData); + }); + + test('GIVEN valid JSON options THEN valid JSON output is given 2', () => { + const item1: APIMediaGalleryItem = { + description: 'test', + spoiler: false, + media: { url: 'https://discord.com/logo.png' }, + }; + const item2 = { + media: { url: 'attachment://file.png' }, + }; + + expect(new MediaGalleryBuilder().addItems(item1).toJSON()).toEqual(galleryHttpsDisplay); + expect(new MediaGalleryBuilder().addItems(item2).setId(123).toJSON()).toEqual(galleryAttachmentData); + expect(new MediaGalleryBuilder().addItems([item1]).toJSON()).toEqual(galleryHttpsDisplay); + expect(new MediaGalleryBuilder().addItems([item2]).setId(123).toJSON()).toEqual(galleryAttachmentData); + }); + + test('GIVEN valid builder callback THEN valid JSON output is given', () => { + const item1 = new MediaGalleryItemBuilder() + .setDescription('test') + .setSpoiler(false) + .setURL('https://discord.com/logo.png'); + const item2 = new MediaGalleryItemBuilder().setURL('attachment://file.png'); + + expect( + new MediaGalleryBuilder() + .addItems((item) => item.setDescription('test').setSpoiler(false).setURL('https://discord.com/logo.png')) + .toJSON(), + ).toEqual(galleryHttpsDisplay); + expect( + new MediaGalleryBuilder() + .spliceItems(0, 0, (item) => item.setURL('attachment://file.png')) + .setId(123) + .toJSON(), + ).toEqual(galleryAttachmentData); + expect( + new MediaGalleryBuilder() + .addItems([(item) => item.setDescription('test').setSpoiler(false).setURL('https://discord.com/logo.png')]) + .toJSON(), + ).toEqual(galleryHttpsDisplay); + expect( + new MediaGalleryBuilder() + .spliceItems(0, 0, [(item) => item.setDescription('test').clearDescription().setURL('attachment://file.png')]) + .setId(123) + .toJSON(), + ).toEqual(galleryAttachmentData); + }); + }); +}); diff --git a/packages/builders/__tests__/components/v2/section.test.ts b/packages/builders/__tests__/components/v2/section.test.ts new file mode 100644 index 000000000000..05af792e0153 --- /dev/null +++ b/packages/builders/__tests__/components/v2/section.test.ts @@ -0,0 +1,191 @@ +import { type APISectionComponent, ButtonStyle, ComponentType } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { createComponentBuilder } from '../../../src/components/Components.js'; +import { ButtonBuilder } from '../../../src/components/button/Button.js'; +import { SectionBuilder } from '../../../src/components/v2/Section.js'; +import { TextDisplayBuilder } from '../../../src/components/v2/TextDisplay.js'; +import { ThumbnailBuilder } from '../../../src/components/v2/Thumbnail.js'; + +const sectionWithButtonData: APISectionComponent = { + type: ComponentType.Section, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accessory: { + type: ComponentType.Button, + label: 'test', + custom_id: '123', + style: ButtonStyle.Primary, + }, +}; + +const sectionWithThumbnailData: APISectionComponent = { + type: ComponentType.Section, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accessory: { + type: ComponentType.Thumbnail, + media: { url: 'attachment://file.png' }, + spoiler: true, + description: 'test', + }, +}; + +describe('Section Components', () => { + describe('Assertion Tests', () => { + test('GIVEN valid components THEN do not throw', () => { + expect(() => new SectionBuilder().addTextDisplayComponents(new TextDisplayBuilder())).not.toThrowError(); + expect(() => new SectionBuilder().spliceTextDisplayComponents(0, 0, new TextDisplayBuilder())).not.toThrowError(); + expect(() => new SectionBuilder().addTextDisplayComponents([new TextDisplayBuilder()])).not.toThrowError(); + expect(() => + new SectionBuilder().spliceTextDisplayComponents(0, 0, [new TextDisplayBuilder()]), + ).not.toThrowError(); + }); + + test('GIVEN valid JSON input THEN valid JSON output is given', () => { + const sectionData: APISectionComponent = { + type: ComponentType.Section, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + id: 123, + }, + { + type: ComponentType.TextDisplay, + content: 'test', + }, + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accessory: { + type: ComponentType.Thumbnail, + media: { url: 'attachment://file.png' }, + }, + }; + + expect(new SectionBuilder(sectionData).toJSON()).toEqual(sectionData); + expect(() => + createComponentBuilder({ + type: ComponentType.Section, + components: [], + accessory: { type: ComponentType.Thumbnail, media: { url: 'https://discord.com/logo.png' } }, + }), + ).not.toThrowError(); + }); + + test('GIVEN valid builder options THEN valid JSON output is given', () => { + const sectionWithButtonData: APISectionComponent = { + type: ComponentType.Section, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accessory: { + type: ComponentType.Button, + label: 'test', + custom_id: '123', + style: ButtonStyle.Primary, + }, + }; + + const sectionWithThumbnailData: APISectionComponent = { + type: ComponentType.Section, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accessory: { + type: ComponentType.Thumbnail, + media: { url: 'attachment://file.png' }, + spoiler: true, + description: 'test', + }, + }; + + expect(new SectionBuilder(sectionWithButtonData).toJSON()).toEqual(sectionWithButtonData); + expect(new SectionBuilder(sectionWithThumbnailData).toJSON()).toEqual(sectionWithThumbnailData); + expect(() => + createComponentBuilder({ + type: ComponentType.Section, + components: [], + accessory: { + type: ComponentType.Button, + label: 'test', + custom_id: '123', + style: ButtonStyle.Primary, + }, + }), + ).not.toThrowError(); + }); + + test('GIVEN valid builder options THEN valid JSON output is given 2', () => { + const button = new ButtonBuilder().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123'); + const thumbnail = new ThumbnailBuilder().setDescription('test').setSpoiler().setURL('attachment://file.png'); + const textDisplay = new TextDisplayBuilder().setContent('test'); + + expect(new SectionBuilder().addTextDisplayComponents(textDisplay).setButtonAccessory(button).toJSON()).toEqual( + sectionWithButtonData, + ); + expect( + new SectionBuilder().addTextDisplayComponents(textDisplay).setThumbnailAccessory(thumbnail).toJSON(), + ).toEqual(sectionWithThumbnailData); + expect( + new SectionBuilder() + .addTextDisplayComponents([textDisplay]) + .setButtonAccessory((button) => button.setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123')) + .toJSON(), + ).toEqual(sectionWithButtonData); + expect( + new SectionBuilder() + .addTextDisplayComponents([textDisplay]) + .setThumbnailAccessory((thumbnail) => + thumbnail.setDescription('test').setSpoiler().setURL('attachment://file.png'), + ) + .toJSON(), + ).toEqual(sectionWithThumbnailData); + }); + + test('GIVEN valid builder callback THEN valid JSON output is given', () => { + const button = new ButtonBuilder().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123'); + + expect( + new SectionBuilder() + .addTextDisplayComponents((textDisplay) => textDisplay.setContent('test')) + .setButtonAccessory(button) + .toJSON(), + ).toEqual(sectionWithButtonData); + expect( + new SectionBuilder() + .spliceTextDisplayComponents(0, 0, (textDisplay) => textDisplay.setContent('test')) + .setButtonAccessory(button) + .toJSON(), + ).toEqual(sectionWithButtonData); + expect( + new SectionBuilder() + .addTextDisplayComponents([(textDisplay) => textDisplay.setContent('test')]) + .setButtonAccessory(button) + .toJSON(), + ).toEqual(sectionWithButtonData); + expect( + new SectionBuilder() + .spliceTextDisplayComponents(0, 0, [(textDisplay) => textDisplay.setContent('test')]) + .setButtonAccessory(button) + .toJSON(), + ).toEqual(sectionWithButtonData); + }); + }); +}); diff --git a/packages/builders/__tests__/components/v2/separator.test.ts b/packages/builders/__tests__/components/v2/separator.test.ts new file mode 100644 index 000000000000..73743548463f --- /dev/null +++ b/packages/builders/__tests__/components/v2/separator.test.ts @@ -0,0 +1,35 @@ +import { ComponentType, SeparatorSpacingSize } from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { SeparatorBuilder } from '../../../src/components/v2/Separator'; + +describe('Separator', () => { + describe('Divider', () => { + test('GIVEN a separator with a pre-defined divider THEN return valid toJSON data', () => { + const separator = new SeparatorBuilder({ divider: true }); + expect(separator.toJSON()).toEqual({ type: ComponentType.Separator, divider: true }); + }); + + test('GIVEN a separator with a set divider THEN return valid toJSON data', () => { + const separator = new SeparatorBuilder().setDivider(false); + expect(separator.toJSON()).toEqual({ type: ComponentType.Separator, divider: false }); + }); + }); + + describe('Spacing', () => { + test('GIVEN a separator with a pre-defined spacing THEN return valid toJSON data', () => { + const separator = new SeparatorBuilder({ spacing: SeparatorSpacingSize.Small }); + expect(separator.toJSON()).toEqual({ type: ComponentType.Separator, spacing: SeparatorSpacingSize.Small }); + }); + + test('GIVEN a separator with a set spacing THEN return valid toJSON data', () => { + const separator = new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large); + expect(separator.toJSON()).toEqual({ type: ComponentType.Separator, spacing: SeparatorSpacingSize.Large }); + }); + + test('GIVEN a separator with a set spacing THEN clear spacing THEN return valid toJSON data', () => { + const separator = new SeparatorBuilder({ spacing: SeparatorSpacingSize.Small }); + separator.clearSpacing(); + expect(separator.toJSON()).toEqual({ type: ComponentType.Separator }); + }); + }); +}); diff --git a/packages/builders/__tests__/components/v2/textDisplay.test.ts b/packages/builders/__tests__/components/v2/textDisplay.test.ts new file mode 100644 index 000000000000..01495c8af9a7 --- /dev/null +++ b/packages/builders/__tests__/components/v2/textDisplay.test.ts @@ -0,0 +1,23 @@ +import { ComponentType } from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { TextDisplayBuilder } from '../../../src/components/v2/TextDisplay'; + +describe('TextDisplay', () => { + describe('TextDisplay content', () => { + test('GIVEN a text display with a pre-defined content THEN return valid toJSON data', () => { + const textDisplay = new TextDisplayBuilder({ content: 'foo' }); + expect(textDisplay.toJSON()).toEqual({ type: ComponentType.TextDisplay, content: 'foo' }); + }); + + test('GIVEN a text display with a set content THEN return valid toJSON data', () => { + const textDisplay = new TextDisplayBuilder().setContent('foo'); + expect(textDisplay.toJSON()).toEqual({ type: ComponentType.TextDisplay, content: 'foo' }); + }); + + test('GIVEN a text display with a pre-defined content THEN overwritten content THEN return valid toJSON data', () => { + const textDisplay = new TextDisplayBuilder({ content: 'foo' }); + textDisplay.setContent('bar'); + expect(textDisplay.toJSON()).toEqual({ type: ComponentType.TextDisplay, content: 'bar' }); + }); + }); +}); diff --git a/packages/builders/__tests__/components/v2/thumbnail.test.ts b/packages/builders/__tests__/components/v2/thumbnail.test.ts new file mode 100644 index 000000000000..5bcd23af75bd --- /dev/null +++ b/packages/builders/__tests__/components/v2/thumbnail.test.ts @@ -0,0 +1,69 @@ +import { ComponentType } from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { ThumbnailBuilder } from '../../../src/components/v2/Thumbnail'; + +const dummy = { + type: ComponentType.Thumbnail as const, + media: { url: 'https://google.com' }, +}; + +describe('Thumbnail', () => { + describe('Thumbnail url', () => { + test('GIVEN a thumbnail with a pre-defined url THEN return valid toJSON data', () => { + const thumbnail = new ThumbnailBuilder({ media: { url: 'https://google.com' } }); + expect(thumbnail.toJSON()).toEqual({ type: ComponentType.Thumbnail, media: { url: 'https://google.com' } }); + }); + + test('GIVEN a thumbnail with a set url THEN return valid toJSON data', () => { + const thumbnail = new ThumbnailBuilder().setURL('https://google.com'); + expect(thumbnail.toJSON()).toEqual({ type: ComponentType.Thumbnail, media: { url: 'https://google.com' } }); + }); + + test.each(['owo', 'discord://user'])('GIVEN a thumbnail with an invalid URL (%s) THEN throws error', (input) => { + const thumbnail = new ThumbnailBuilder(); + + expect(() => thumbnail.setURL(input)).toThrowError(); + }); + }); + + describe('Thumbnail description', () => { + test('GIVEN a thumbnail with a pre-defined description THEN return valid toJSON data', () => { + const thumbnail = new ThumbnailBuilder({ ...dummy, description: 'foo' }); + expect(thumbnail.toJSON()).toEqual({ ...dummy, description: 'foo' }); + }); + + test('GIVEN a thumbnail with a set description THEN return valid toJSON data', () => { + const thumbnail = new ThumbnailBuilder({ ...dummy }); + thumbnail.setDescription('foo'); + + expect(thumbnail.toJSON()).toEqual({ ...dummy, description: 'foo' }); + }); + + test('GIVEN a thumbnail with a pre-defined description THEN unset description THEN return valid toJSON data', () => { + const thumbnail = new ThumbnailBuilder({ description: 'foo', ...dummy }); + thumbnail.clearDescription(); + + expect(thumbnail.toJSON()).toEqual({ ...dummy }); + }); + + test('GIVEN a thumbnail with an invalid description THEN throws error', () => { + const thumbnail = new ThumbnailBuilder(); + + expect(() => thumbnail.setDescription('a'.repeat(1_025))).toThrowError(); + }); + }); + + describe('Thumbnail spoiler', () => { + test('GIVEN a thumbnail with a pre-defined spoiler status THEN return valid toJSON data', () => { + const thumbnail = new ThumbnailBuilder({ ...dummy, spoiler: true }); + expect(thumbnail.toJSON()).toEqual({ ...dummy, spoiler: true }); + }); + + test('GIVEN a thumbnail with a set spoiler status THEN return valid toJSON data', () => { + const thumbnail = new ThumbnailBuilder({ ...dummy }); + thumbnail.setSpoiler(false); + + expect(thumbnail.toJSON()).toEqual({ ...dummy, spoiler: false }); + }); + }); +}); diff --git a/packages/builders/package.json b/packages/builders/package.json index 56d1df0608a7..7e47b910f223 100644 --- a/packages/builders/package.json +++ b/packages/builders/package.json @@ -68,7 +68,7 @@ "@discordjs/formatters": "workspace:^", "@discordjs/util": "workspace:^", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.37.119", + "discord-api-types": "^0.38.1", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" diff --git a/packages/builders/src/components/ActionRow.ts b/packages/builders/src/components/ActionRow.ts index ade84ac4690c..6953d5f8d572 100644 --- a/packages/builders/src/components/ActionRow.ts +++ b/packages/builders/src/components/ActionRow.ts @@ -3,9 +3,9 @@ import { type APIActionRowComponent, ComponentType, - type APIMessageActionRowComponent, - type APIModalActionRowComponent, - type APIActionRowComponentTypes, + type APIComponentInMessageActionRow, + type APIComponentInModalActionRow, + type APIComponentInActionRow, } from 'discord-api-types/v10'; import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js'; import { ComponentBuilder } from './Component.js'; @@ -18,13 +18,6 @@ import type { StringSelectMenuBuilder } from './selectMenu/StringSelectMenu.js'; import type { UserSelectMenuBuilder } from './selectMenu/UserSelectMenu.js'; import type { TextInputBuilder } from './textInput/TextInput.js'; -/** - * The builders that may be used for messages. - */ -export type MessageComponentBuilder = - | ActionRowBuilder - | MessageActionRowComponentBuilder; - /** * The builders that may be used for modals. */ @@ -57,7 +50,7 @@ export type AnyComponentBuilder = MessageActionRowComponentBuilder | ModalAction * @typeParam ComponentType - The types of components this action row holds */ export class ActionRowBuilder extends ComponentBuilder< - APIActionRowComponent + APIActionRowComponent > { /** * The components within this action row. @@ -98,7 +91,7 @@ export class ActionRowBuilder extends * .addComponents(button2, button3); * ``` */ - public constructor({ components, ...data }: Partial> = {}) { + public constructor({ components, ...data }: Partial> = {}) { super({ type: ComponentType.ActionRow, ...data }); this.components = (components?.map((component) => createComponentBuilder(component)) ?? []) as ComponentType[]; } diff --git a/packages/builders/src/components/Assertions.ts b/packages/builders/src/components/Assertions.ts index 926159eedc08..7165a5dd6850 100644 --- a/packages/builders/src/components/Assertions.ts +++ b/packages/builders/src/components/Assertions.ts @@ -3,6 +3,13 @@ import { ButtonStyle, ChannelType, type APIMessageComponentEmoji } from 'discord import { isValidationEnabled } from '../util/validation.js'; import { StringSelectMenuOptionBuilder } from './selectMenu/StringSelectMenuOption.js'; +export const idValidator = s + .number() + .safeInt() + .greaterThanOrEqual(1) + .lessThan(4_294_967_296) // 2^32 - 1 + .setValidationEnabled(isValidationEnabled); + export const customIdValidator = s .string() .lengthGreaterThanOrEqual(1) diff --git a/packages/builders/src/components/Component.ts b/packages/builders/src/components/Component.ts index e5e59638dfb9..bd6e9f1bb22a 100644 --- a/packages/builders/src/components/Component.ts +++ b/packages/builders/src/components/Component.ts @@ -1,15 +1,20 @@ import type { JSONEncodable } from '@discordjs/util'; import type { APIActionRowComponent, - APIActionRowComponentTypes, + APIComponentInActionRow, APIBaseComponent, ComponentType, + APIMessageComponent, } from 'discord-api-types/v10'; +import { idValidator } from './Assertions'; /** * Any action row component data represented as an object. */ -export type AnyAPIActionRowComponent = APIActionRowComponent | APIActionRowComponentTypes; +export type AnyAPIActionRowComponent = + | APIActionRowComponent + | APIComponentInActionRow + | APIMessageComponent; /** * The base component builder that contains common symbols for all sorts of components. @@ -42,4 +47,22 @@ export abstract class ComponentBuilder< public constructor(data: Partial) { this.data = data; } + + /** + * Sets the id (not the custom id) for this component. + * + * @param id - The id for this component + */ + public setId(id: number) { + this.data.id = idValidator.parse(id); + return this; + } + + /** + * Clears the id of this component, defaulting to a default incremented id. + */ + public clearId() { + this.data.id = undefined; + return this; + } } diff --git a/packages/builders/src/components/Components.ts b/packages/builders/src/components/Components.ts index 18b0dff6dd77..a2a03ff8b96c 100644 --- a/packages/builders/src/components/Components.ts +++ b/packages/builders/src/components/Components.ts @@ -1,8 +1,9 @@ +import type { JSONEncodable } from '@discordjs/util'; import { ComponentType, type APIMessageComponent, type APIModalComponent } from 'discord-api-types/v10'; import { ActionRowBuilder, + type MessageActionRowComponentBuilder, type AnyComponentBuilder, - type MessageComponentBuilder, type ModalComponentBuilder, } from './ActionRow.js'; import { ComponentBuilder } from './Component.js'; @@ -13,6 +14,27 @@ import { RoleSelectMenuBuilder } from './selectMenu/RoleSelectMenu.js'; import { StringSelectMenuBuilder } from './selectMenu/StringSelectMenu.js'; import { UserSelectMenuBuilder } from './selectMenu/UserSelectMenu.js'; import { TextInputBuilder } from './textInput/TextInput.js'; +import { ContainerBuilder } from './v2/Container.js'; +import { FileBuilder } from './v2/File.js'; +import { MediaGalleryBuilder } from './v2/MediaGallery.js'; +import { SectionBuilder } from './v2/Section.js'; +import { SeparatorBuilder } from './v2/Separator.js'; +import { TextDisplayBuilder } from './v2/TextDisplay.js'; +import { ThumbnailBuilder } from './v2/Thumbnail.js'; + +/** + * The builders that may be used for messages. + */ +export type MessageComponentBuilder = + | ActionRowBuilder + | ContainerBuilder + | FileBuilder + | MediaGalleryBuilder + | MessageActionRowComponentBuilder + | SectionBuilder + | SeparatorBuilder + | TextDisplayBuilder + | ThumbnailBuilder; /** * Components here are mapped to their respective builder. @@ -50,6 +72,34 @@ export interface MappedComponentTypes { * The channel select component type is associated with a {@link ChannelSelectMenuBuilder}. */ [ComponentType.ChannelSelect]: ChannelSelectMenuBuilder; + /** + * The file component type is associated with a {@link FileBuilder}. + */ + [ComponentType.File]: FileBuilder; + /** + * The separator component type is associated with a {@link SeparatorBuilder}. + */ + [ComponentType.Separator]: SeparatorBuilder; + /** + * The container component type is associated with a {@link ContainerBuilder}. + */ + [ComponentType.Container]: ContainerBuilder; + /** + * The text display component type is associated with a {@link TextDisplayBuilder}. + */ + [ComponentType.TextDisplay]: TextDisplayBuilder; + /** + * The thumbnail component type is associated with a {@link ThumbnailBuilder}. + */ + [ComponentType.Thumbnail]: ThumbnailBuilder; + /** + * The section component type is associated with a {@link SectionBuilder}. + */ + [ComponentType.Section]: SectionBuilder; + /** + * The media gallery component type is associated with a {@link MediaGalleryBuilder}. + */ + [ComponentType.MediaGallery]: MediaGalleryBuilder; } /** @@ -97,8 +147,44 @@ export function createComponentBuilder( return new MentionableSelectMenuBuilder(data); case ComponentType.ChannelSelect: return new ChannelSelectMenuBuilder(data); + case ComponentType.File: + return new FileBuilder(data); + case ComponentType.Container: + return new ContainerBuilder(data); + case ComponentType.Section: + return new SectionBuilder(data); + case ComponentType.Separator: + return new SeparatorBuilder(data); + case ComponentType.TextDisplay: + return new TextDisplayBuilder(data); + case ComponentType.Thumbnail: + return new ThumbnailBuilder(data); + case ComponentType.MediaGallery: + return new MediaGalleryBuilder(data); default: // @ts-expect-error This case can still occur if we get a newer unsupported component type throw new Error(`Cannot properly serialize component type: ${data.type}`); } } + +function isBuilder>( + builder: unknown, + Constructor: new () => Builder, +): builder is Builder { + return builder instanceof Constructor; +} + +export function resolveBuilder, Builder extends JSONEncodable>( + builder: Builder | ComponentType | ((builder: Builder) => Builder), + Constructor: new (data?: ComponentType) => Builder, +) { + if (isBuilder(builder, Constructor)) { + return builder; + } + + if (typeof builder === 'function') { + return builder(new Constructor()); + } + + return new Constructor(builder); +} diff --git a/packages/builders/src/components/v2/Assertions.ts b/packages/builders/src/components/v2/Assertions.ts new file mode 100644 index 000000000000..6bcbff8c308d --- /dev/null +++ b/packages/builders/src/components/v2/Assertions.ts @@ -0,0 +1,71 @@ +import { s } from '@sapphire/shapeshift'; +import { SeparatorSpacingSize } from 'discord-api-types/v10'; +import { colorPredicate } from '../../messages/embed/Assertions'; +import { isValidationEnabled } from '../../util/validation'; +import { ComponentBuilder } from '../Component'; +import { ButtonBuilder } from '../button/Button'; +import type { ContainerComponentBuilder } from './Container'; +import type { MediaGalleryItemBuilder } from './MediaGalleryItem'; +import type { TextDisplayBuilder } from './TextDisplay'; +import { ThumbnailBuilder } from './Thumbnail'; + +export const unfurledMediaItemPredicate = s + .object({ + url: s + .string() + .url( + { allowedProtocols: ['http:', 'https:', 'attachment:'] }, + { message: 'Invalid protocol for media URL. Must be http:, https:, or attachment:' }, + ), + }) + .setValidationEnabled(isValidationEnabled); + +export const descriptionPredicate = s + .string() + .lengthGreaterThanOrEqual(1) + .lengthLessThanOrEqual(1_024) + .setValidationEnabled(isValidationEnabled); + +export const filePredicate = s + .object({ + url: s + .string() + .url({ allowedProtocols: ['attachment:'] }, { message: 'Invalid protocol for file URL. Must be attachment:' }), + }) + .setValidationEnabled(isValidationEnabled); + +export const spoilerPredicate = s.boolean(); + +export const dividerPredicate = s.boolean(); + +export const spacingPredicate = s.nativeEnum(SeparatorSpacingSize); + +export const textDisplayContentPredicate = s + .string() + .lengthGreaterThanOrEqual(1) + .lengthLessThanOrEqual(4_000) + .setValidationEnabled(isValidationEnabled); + +export const accessoryPredicate = s + .instance(ButtonBuilder) + .or(s.instance(ThumbnailBuilder)) + .setValidationEnabled(isValidationEnabled); + +export const containerColorPredicate = colorPredicate.nullish(); + +export function assertReturnOfBuilder( + input: unknown, + ExpectedInstanceOf: new () => ReturnType, +): asserts input is ReturnType { + s.instance(ExpectedInstanceOf).parse(input); +} + +export function validateComponentArray< + ReturnType extends ContainerComponentBuilder | MediaGalleryItemBuilder = ContainerComponentBuilder, +>(input: unknown, min: number, max: number, ExpectedInstanceOf?: new () => ReturnType): asserts input is ReturnType[] { + (ExpectedInstanceOf ? s.instance(ExpectedInstanceOf) : s.instance(ComponentBuilder)) + .array() + .lengthGreaterThanOrEqual(min) + .lengthLessThanOrEqual(max) + .parse(input); +} diff --git a/packages/builders/src/components/v2/Container.ts b/packages/builders/src/components/v2/Container.ts new file mode 100644 index 000000000000..a446f3db9a71 --- /dev/null +++ b/packages/builders/src/components/v2/Container.ts @@ -0,0 +1,240 @@ +/* eslint-disable jsdoc/check-param-names */ + +import type { + APIActionRowComponent, + APIComponentInContainer, + APIComponentInMessageActionRow, + APIContainerComponent, + APIFileComponent, + APIMediaGalleryComponent, + APISectionComponent, + APISeparatorComponent, + APITextDisplayComponent, +} from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import type { RGBTuple } from '../../index.js'; +import { MediaGalleryBuilder, SectionBuilder } from '../../index.js'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; +import type { AnyComponentBuilder, MessageActionRowComponentBuilder } from '../ActionRow.js'; +import { ActionRowBuilder } from '../ActionRow.js'; +import { ComponentBuilder } from '../Component.js'; +import { createComponentBuilder, resolveBuilder } from '../Components.js'; +import { containerColorPredicate, spoilerPredicate, validateComponentArray } from './Assertions.js'; +import { FileBuilder } from './File.js'; +import { SeparatorBuilder } from './Separator.js'; +import { TextDisplayBuilder } from './TextDisplay.js'; + +/** + * The builders that may be used within a container. + */ +export type ContainerComponentBuilder = + | ActionRowBuilder + | FileBuilder + | MediaGalleryBuilder + | SectionBuilder + | SeparatorBuilder + | TextDisplayBuilder; + +/** + * A builder that creates API-compatible JSON data for a container. + */ +export class ContainerBuilder extends ComponentBuilder { + /** + * The components within this container. + */ + public readonly components: ContainerComponentBuilder[]; + + /** + * Creates a new container from API data. + * + * @param data - The API data to create this container with + * @example + * Creating a container from an API data object: + * ```ts + * const container = new ContainerBuilder({ + * components: [ + * { + * content: "Some text here", + * type: ComponentType.TextDisplay, + * }, + * ], + * }); + * ``` + * @example + * Creating a container using setters and API data: + * ```ts + * const container = new ContainerBuilder({ + * components: [ + * { + * content: "# Heading", + * type: ComponentType.TextDisplay, + * }, + * ], + * }) + * .addComponents(separator, section); + * ``` + */ + public constructor({ components, ...data }: Partial = {}) { + super({ type: ComponentType.Container, ...data }); + this.components = (components?.map((component) => createComponentBuilder(component)) ?? + []) as ContainerComponentBuilder[]; + } + + /** + * Sets the accent color of this container. + * + * @param color - The color to use + */ + public setAccentColor(color?: RGBTuple | number): this { + // Data assertions + containerColorPredicate.parse(color); + + if (Array.isArray(color)) { + const [red, green, blue] = color; + this.data.accent_color = (red << 16) + (green << 8) + blue; + return this; + } + + this.data.accent_color = color; + return this; + } + + /** + * Clears the accent color of this container. + */ + public clearAccentColor() { + this.data.accent_color = undefined; + return this; + } + + /** + * Adds action row components to this container. + * + * @param components - The action row components to add + */ + public addActionRowComponents( + ...components: RestOrArray< + | ActionRowBuilder + | APIActionRowComponent + | ((builder: ActionRowBuilder) => ActionRowBuilder) + > + ) { + this.components.push( + ...normalizeArray(components).map((component) => resolveBuilder(component, ActionRowBuilder)), + ); + return this; + } + + /** + * Adds file components to this container. + * + * @param components - The file components to add + */ + public addFileComponents( + ...components: RestOrArray FileBuilder)> + ) { + this.components.push(...normalizeArray(components).map((component) => resolveBuilder(component, FileBuilder))); + return this; + } + + /** + * Adds media gallery components to this container. + * + * @param components - The media gallery components to add + */ + public addMediaGalleryComponents( + ...components: RestOrArray< + APIMediaGalleryComponent | MediaGalleryBuilder | ((builder: MediaGalleryBuilder) => MediaGalleryBuilder) + > + ) { + this.components.push( + ...normalizeArray(components).map((component) => resolveBuilder(component, MediaGalleryBuilder)), + ); + return this; + } + + /** + * Adds section components to this container. + * + * @param components - The section components to add + */ + public addSectionComponents( + ...components: RestOrArray SectionBuilder)> + ) { + this.components.push(...normalizeArray(components).map((component) => resolveBuilder(component, SectionBuilder))); + return this; + } + + /** + * Adds separator components to this container. + * + * @param components - The separator components to add + */ + public addSeparatorComponents( + ...components: RestOrArray< + APISeparatorComponent | SeparatorBuilder | ((builder: SeparatorBuilder) => SeparatorBuilder) + > + ) { + this.components.push(...normalizeArray(components).map((component) => resolveBuilder(component, SeparatorBuilder))); + return this; + } + + /** + * Adds text display components to this container. + * + * @param components - The text display components to add + */ + public addTextDisplayComponents( + ...components: RestOrArray< + APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder) + > + ) { + this.components.push( + ...normalizeArray(components).map((component) => resolveBuilder(component, TextDisplayBuilder)), + ); + return this; + } + + /** + * Removes, replaces, or inserts components for this container. + * + * @param index - The index to start removing, replacing or inserting components + * @param deleteCount - The amount of components to remove + * @param components - The components to set + */ + public spliceComponents( + index: number, + deleteCount: number, + ...components: RestOrArray + ) { + this.components.splice( + index, + deleteCount, + ...normalizeArray(components).map((component) => + component instanceof ComponentBuilder ? component : createComponentBuilder(component), + ), + ); + return this; + } + + /** + * Sets the spoiler status of this container. + * + * @param spoiler - The spoiler status to use + */ + public setSpoiler(spoiler = true) { + this.data.spoiler = spoilerPredicate.parse(spoiler); + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public toJSON(): APIContainerComponent { + validateComponentArray(this.components, 1, 10); + return { + ...this.data, + components: this.components.map((component) => component.toJSON()), + } as APIContainerComponent; + } +} diff --git a/packages/builders/src/components/v2/File.ts b/packages/builders/src/components/v2/File.ts new file mode 100644 index 000000000000..fb92a82d8ffc --- /dev/null +++ b/packages/builders/src/components/v2/File.ts @@ -0,0 +1,63 @@ +import { ComponentType, type APIFileComponent } from 'discord-api-types/v10'; +import { ComponentBuilder } from '../Component'; +import { filePredicate, spoilerPredicate } from './Assertions'; + +export class FileBuilder extends ComponentBuilder { + /** + * Creates a new file from API data. + * + * @param data - The API data to create this file with + * @example + * Creating a file from an API data object: + * ```ts + * const file = new FileBuilder({ + * spoiler: true, + * file: { + * url: 'attachment://file.png', + * }, + * }); + * ``` + * @example + * Creating a file using setters and API data: + * ```ts + * const file = new FileBuilder({ + * file: { + * url: 'attachment://image.jpg', + * }, + * }) + * .setSpoiler(false); + * ``` + */ + public constructor(data: Partial = {}) { + super({ type: ComponentType.File, ...data, file: data.file ? { url: data.file.url } : undefined }); + } + + /** + * Sets the spoiler status of this file. + * + * @param spoiler - The spoiler status to use + */ + public setSpoiler(spoiler = true) { + this.data.spoiler = spoilerPredicate.parse(spoiler); + return this; + } + + /** + * Sets the media URL of this file. + * + * @param url - The URL to use + */ + public setURL(url: string) { + this.data.file = filePredicate.parse({ url }); + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(): APIFileComponent { + filePredicate.parse(this.data.file); + + return { ...this.data, file: { ...this.data.file } } as APIFileComponent; + } +} diff --git a/packages/builders/src/components/v2/MediaGallery.ts b/packages/builders/src/components/v2/MediaGallery.ts new file mode 100644 index 000000000000..cad2b5a22a5e --- /dev/null +++ b/packages/builders/src/components/v2/MediaGallery.ts @@ -0,0 +1,117 @@ +/* eslint-disable jsdoc/check-param-names */ + +import type { APIMediaGalleryComponent, APIMediaGalleryItem } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; +import { ComponentBuilder } from '../Component.js'; +import { resolveBuilder } from '../Components.js'; +import { assertReturnOfBuilder, validateComponentArray } from './Assertions.js'; +import { MediaGalleryItemBuilder } from './MediaGalleryItem.js'; + +/** + * A builder that creates API-compatible JSON data for a container. + */ +export class MediaGalleryBuilder extends ComponentBuilder { + /** + * The components within this container. + */ + public readonly items: MediaGalleryItemBuilder[]; + + /** + * Creates a new media gallery from API data. + * + * @param data - The API data to create this media gallery with + * @example + * Creating a media gallery from an API data object: + * ```ts + * const mediaGallery = new MediaGalleryBuilder({ + * items: [ + * { + * description: "Some text here", + * media: { + * url: 'https://cdn.discordapp.com/embed/avatars/2.png', + * }, + * }, + * ], + * }); + * ``` + * @example + * Creating a media gallery using setters and API data: + * ```ts + * const mediaGallery = new MediaGalleryBuilder({ + * items: [ + * { + * description: "alt text", + * media: { + * url: 'https://cdn.discordapp.com/embed/avatars/5.png', + * }, + * }, + * ], + * }) + * .addItems(item2, item3); + * ``` + */ + public constructor({ items, ...data }: Partial = {}) { + super({ type: ComponentType.MediaGallery, ...data }); + this.items = items?.map((item) => new MediaGalleryItemBuilder(item)) ?? []; + } + + /** + * Adds items to this media gallery. + * + * @param items - The items to add + */ + public addItems( + ...items: RestOrArray< + APIMediaGalleryItem | MediaGalleryItemBuilder | ((builder: MediaGalleryItemBuilder) => MediaGalleryItemBuilder) + > + ) { + this.items.push( + ...normalizeArray(items).map((input) => { + const result = resolveBuilder(input, MediaGalleryItemBuilder); + + assertReturnOfBuilder(result, MediaGalleryItemBuilder); + return result; + }), + ); + return this; + } + + /** + * Removes, replaces, or inserts media gallery items for this media gallery. + * + * @param index - The index to start removing, replacing or inserting items + * @param deleteCount - The amount of items to remove + * @param items - The items to insert + */ + public spliceItems( + index: number, + deleteCount: number, + ...items: RestOrArray< + APIMediaGalleryItem | MediaGalleryItemBuilder | ((builder: MediaGalleryItemBuilder) => MediaGalleryItemBuilder) + > + ) { + this.items.splice( + index, + deleteCount, + ...normalizeArray(items).map((input) => { + const result = resolveBuilder(input, MediaGalleryItemBuilder); + + assertReturnOfBuilder(result, MediaGalleryItemBuilder); + return result; + }), + ); + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public toJSON(): APIMediaGalleryComponent { + validateComponentArray(this.items, 1, 10, MediaGalleryItemBuilder); + return { + ...this.data, + items: this.items.map((item) => item.toJSON()), + } as APIMediaGalleryComponent; + } +} diff --git a/packages/builders/src/components/v2/MediaGalleryItem.ts b/packages/builders/src/components/v2/MediaGalleryItem.ts new file mode 100644 index 000000000000..9e33298b79e4 --- /dev/null +++ b/packages/builders/src/components/v2/MediaGalleryItem.ts @@ -0,0 +1,90 @@ +import type { JSONEncodable } from '@discordjs/util'; +import type { APIMediaGalleryItem } from 'discord-api-types/v10'; +import { descriptionPredicate, spoilerPredicate, unfurledMediaItemPredicate } from './Assertions'; + +export class MediaGalleryItemBuilder implements JSONEncodable { + /** + * The API data associated with this media gallery item. + */ + public readonly data: Partial; + + /** + * Creates a new media gallery item from API data. + * + * @param data - The API data to create this media gallery item with + * @example + * Creating a media gallery item from an API data object: + * ```ts + * const item = new MediaGalleryItemBuilder({ + * description: "Some text here", + * media: { + * url: 'https://cdn.discordapp.com/embed/avatars/2.png', + * }, + * }); + * ``` + * @example + * Creating a media gallery item using setters and API data: + * ```ts + * const item = new MediaGalleryItemBuilder({ + * media: { + * url: 'https://cdn.discordapp.com/embed/avatars/5.png', + * }, + * }) + * .setDescription("alt text"); + * ``` + */ + public constructor(data: Partial = {}) { + this.data = data; + } + + /** + * Sets the description of this media gallery item. + * + * @param description - The description to use + */ + public setDescription(description: string) { + this.data.description = descriptionPredicate.parse(description); + return this; + } + + /** + * Clears the description of this media gallery item. + */ + public clearDescription() { + this.data.description = undefined; + return this; + } + + /** + * Sets the spoiler status of this media gallery item. + * + * @param spoiler - The spoiler status to use + */ + public setSpoiler(spoiler = true) { + this.data.spoiler = spoilerPredicate.parse(spoiler); + return this; + } + + /** + * Sets the media URL of this media gallery item. + * + * @param url - The URL to use + */ + public setURL(url: string) { + this.data.media = unfurledMediaItemPredicate.parse({ url }); + return this; + } + + /** + * Serializes this builder to API-compatible JSON data. + * + * @remarks + * This method runs validations on the data before serializing it. + * As such, it may throw an error if the data is invalid. + */ + public toJSON(): APIMediaGalleryItem { + unfurledMediaItemPredicate.parse(this.data.media); + + return { ...this.data } as APIMediaGalleryItem; + } +} diff --git a/packages/builders/src/components/v2/Section.ts b/packages/builders/src/components/v2/Section.ts new file mode 100644 index 000000000000..432d9ba2d482 --- /dev/null +++ b/packages/builders/src/components/v2/Section.ts @@ -0,0 +1,153 @@ +/* eslint-disable jsdoc/check-param-names */ + +import type { + APIButtonComponent, + APISectionComponent, + APITextDisplayComponent, + APIThumbnailComponent, +} from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { ButtonBuilder, ThumbnailBuilder } from '../../index.js'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; +import { ComponentBuilder } from '../Component.js'; +import { createComponentBuilder, resolveBuilder } from '../Components.js'; +import { accessoryPredicate, assertReturnOfBuilder, validateComponentArray } from './Assertions.js'; +import { TextDisplayBuilder } from './TextDisplay.js'; + +/** + * A builder that creates API-compatible JSON data for a section. + */ +export class SectionBuilder extends ComponentBuilder { + /** + * The components within this section. + */ + public readonly components: ComponentBuilder[]; + + /** + * The accessory of this section. + */ + public readonly accessory?: ButtonBuilder | ThumbnailBuilder; + + /** + * Creates a new section from API data. + * + * @param data - The API data to create this section with + * @example + * Creating a section from an API data object: + * ```ts + * const section = new SectionBuilder({ + * components: [ + * { + * content: "Some text here", + * type: ComponentType.TextDisplay, + * }, + * ], + * accessory: { + * media: { + * url: 'https://cdn.discordapp.com/embed/avatars/3.png', + * }, + * } + * }); + * ``` + * @example + * Creating a section using setters and API data: + * ```ts + * const section = new SectionBuilder({ + * components: [ + * { + * content: "# Heading", + * type: ComponentType.TextDisplay, + * }, + * ], + * }) + * .setPrimaryButtonAccessory(button); + * ``` + */ + public constructor({ components, accessory, ...data }: Partial = {}) { + super({ type: ComponentType.Section, ...data }); + this.components = (components?.map((component) => createComponentBuilder(component)) ?? []) as ComponentBuilder[]; + this.accessory = accessory ? createComponentBuilder(accessory) : undefined; + } + + /** + * Sets the accessory of this section to a button. + * + * @param accessory - The accessory to use + */ + public setButtonAccessory( + accessory: APIButtonComponent | ButtonBuilder | ((builder: ButtonBuilder) => ButtonBuilder), + ): this { + Reflect.set(this, 'accessory', accessoryPredicate.parse(resolveBuilder(accessory, ButtonBuilder))); + return this; + } + + /** + * Sets the accessory of this section to a thumbnail. + * + * @param accessory - The accessory to use + */ + public setThumbnailAccessory( + accessory: APIThumbnailComponent | ThumbnailBuilder | ((builder: ThumbnailBuilder) => ThumbnailBuilder), + ): this { + Reflect.set(this, 'accessory', accessoryPredicate.parse(resolveBuilder(accessory, ThumbnailBuilder))); + return this; + } + + /** + * Adds text display components to this section. + * + * @param components - The text display components to add + */ + public addTextDisplayComponents( + ...components: RestOrArray TextDisplayBuilder)> + ) { + this.components.push( + ...normalizeArray(components).map((input) => { + const result = resolveBuilder(input, TextDisplayBuilder); + + assertReturnOfBuilder(result, TextDisplayBuilder); + return result; + }), + ); + return this; + } + + /** + * Removes, replaces, or inserts text display components for this section. + * + * @param index - The index to start removing, replacing or inserting text display components + * @param deleteCount - The amount of text display components to remove + * @param components - The text display components to insert + */ + public spliceTextDisplayComponents( + index: number, + deleteCount: number, + ...components: RestOrArray< + APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder) + > + ) { + this.components.splice( + index, + deleteCount, + ...normalizeArray(components).map((input) => { + const result = resolveBuilder(input, TextDisplayBuilder); + + assertReturnOfBuilder(result, TextDisplayBuilder); + return result; + }), + ); + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public toJSON(): APISectionComponent { + validateComponentArray(this.components, 1, 3, TextDisplayBuilder); + return { + ...this.data, + components: this.components.map((component) => component.toJSON()), + accessory: accessoryPredicate.parse(this.accessory).toJSON(), + } as APISectionComponent; + } +} diff --git a/packages/builders/src/components/v2/Separator.ts b/packages/builders/src/components/v2/Separator.ts new file mode 100644 index 000000000000..579921885684 --- /dev/null +++ b/packages/builders/src/components/v2/Separator.ts @@ -0,0 +1,69 @@ +import type { SeparatorSpacingSize, APISeparatorComponent } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { ComponentBuilder } from '../Component'; +import { dividerPredicate, spacingPredicate } from './Assertions'; + +export class SeparatorBuilder extends ComponentBuilder { + /** + * Creates a new separator from API data. + * + * @param data - The API data to create this separator with + * @example + * Creating a separator from an API data object: + * ```ts + * const separator = new SeparatorBuilder({ + * spacing: SeparatorSpacingSize.Small, + * divider: true, + * }); + * ``` + * @example + * Creating a separator using setters and API data: + * ```ts + * const separator = new SeparatorBuilder({ + * spacing: SeparatorSpacingSize.Large, + * }) + * .setDivider(false); + * ``` + */ + public constructor(data: Partial = {}) { + super({ + type: ComponentType.Separator, + ...data, + }); + } + + /** + * Sets whether this separator should show a divider line. + * + * @param divider - Whether to show a divider line + */ + public setDivider(divider = true) { + this.data.divider = dividerPredicate.parse(divider); + return this; + } + + /** + * Sets the spacing of this separator. + * + * @param spacing - The spacing to use + */ + public setSpacing(spacing: SeparatorSpacingSize) { + this.data.spacing = spacingPredicate.parse(spacing); + return this; + } + + /** + * Clears the spacing of this separator. + */ + public clearSpacing() { + this.data.spacing = undefined; + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(): APISeparatorComponent { + return { ...this.data } as APISeparatorComponent; + } +} diff --git a/packages/builders/src/components/v2/TextDisplay.ts b/packages/builders/src/components/v2/TextDisplay.ts new file mode 100644 index 000000000000..61bfefa4f3e4 --- /dev/null +++ b/packages/builders/src/components/v2/TextDisplay.ts @@ -0,0 +1,52 @@ +import type { APITextDisplayComponent } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { ComponentBuilder } from '../Component'; +import { textDisplayContentPredicate } from './Assertions'; + +export class TextDisplayBuilder extends ComponentBuilder { + /** + * Creates a new text display from API data. + * + * @param data - The API data to create this text display with + * @example + * Creating a text display from an API data object: + * ```ts + * const textDisplay = new TextDisplayBuilder({ + * content: 'some text', + * }); + * ``` + * @example + * Creating a text display using setters and API data: + * ```ts + * const textDisplay = new TextDisplayBuilder({ + * content: 'old text', + * }) + * .setContent('new text'); + * ``` + */ + public constructor(data: Partial = {}) { + super({ + type: ComponentType.TextDisplay, + ...data, + }); + } + + /** + * Sets the text of this text display. + * + * @param content - The text to use + */ + public setContent(content: string) { + this.data.content = textDisplayContentPredicate.parse(content); + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(): APITextDisplayComponent { + textDisplayContentPredicate.parse(this.data.content); + + return { ...this.data } as APITextDisplayComponent; + } +} diff --git a/packages/builders/src/components/v2/Thumbnail.ts b/packages/builders/src/components/v2/Thumbnail.ts new file mode 100644 index 000000000000..f10922a043d8 --- /dev/null +++ b/packages/builders/src/components/v2/Thumbnail.ts @@ -0,0 +1,86 @@ +import type { APIThumbnailComponent } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { ComponentBuilder } from '../Component'; +import { descriptionPredicate, spoilerPredicate, unfurledMediaItemPredicate } from './Assertions'; + +export class ThumbnailBuilder extends ComponentBuilder { + /** + * Creates a new thumbnail from API data. + * + * @param data - The API data to create this thumbnail with + * @example + * Creating a thumbnail from an API data object: + * ```ts + * const thumbnaik = new ThumbnailBuilder({ + * description: 'some text', + * media: { + * url: 'https://cdn.discordapp.com/embed/avatars/4.png', + * }, + * }); + * ``` + * @example + * Creating a thumbnail using setters and API data: + * ```ts + * const thumbnail = new ThumbnailBuilder({ + * media: { + * url: 'attachment://image.png', + * }, + * }) + * .setDescription('alt text'); + * ``` + */ + public constructor(data: Partial = {}) { + super({ + type: ComponentType.Thumbnail, + ...data, + media: data.media ? { url: data.media.url } : undefined, + }); + } + + /** + * Sets the description of this thumbnail. + * + * @param description - The description to use + */ + public setDescription(description: string) { + this.data.description = descriptionPredicate.parse(description); + return this; + } + + /** + * Clears the description of this thumbnail. + */ + public clearDescription() { + this.data.description = undefined; + return this; + } + + /** + * Sets the spoiler status of this thumbnail. + * + * @param spoiler - The spoiler status to use + */ + public setSpoiler(spoiler = true) { + this.data.spoiler = spoilerPredicate.parse(spoiler); + return this; + } + + /** + * Sets the media URL of this thumbnail. + * + * @param url - The URL to use + */ + public setURL(url: string) { + this.data.media = unfurledMediaItemPredicate.parse({ url }); + return this; + } + + /** + * {@inheritdoc ComponentBuilder.toJSON} + */ + public override toJSON(): APIThumbnailComponent { + unfurledMediaItemPredicate.parse(this.data.media); + + return { ...this.data } as APIThumbnailComponent; + } +} diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 53908612197a..62030af0dc42 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -34,6 +34,16 @@ export { export * from './components/selectMenu/StringSelectMenuOption.js'; export * from './components/selectMenu/UserSelectMenu.js'; +export * as ComponentsV2Assertions from './components/v2/Assertions.js'; +export * from './components/v2/Container.js'; +export * from './components/v2/File.js'; +export * from './components/v2/MediaGallery.js'; +export * from './components/v2/MediaGalleryItem.js'; +export * from './components/v2/Section.js'; +export * from './components/v2/Separator.js'; +export * from './components/v2/TextDisplay.js'; +export * from './components/v2/Thumbnail.js'; + export * as SlashCommandAssertions from './interactions/slashCommands/Assertions.js'; export * from './interactions/slashCommands/SlashCommandBuilder.js'; export * from './interactions/slashCommands/SlashCommandSubcommands.js'; diff --git a/packages/builders/src/interactions/modals/Modal.ts b/packages/builders/src/interactions/modals/Modal.ts index 948d774df203..513b367657b3 100644 --- a/packages/builders/src/interactions/modals/Modal.ts +++ b/packages/builders/src/interactions/modals/Modal.ts @@ -3,7 +3,7 @@ import type { JSONEncodable } from '@discordjs/util'; import type { APIActionRowComponent, - APIModalActionRowComponent, + APIComponentInModalActionRow, APIModalInteractionResponseCallbackData, } from 'discord-api-types/v10'; import { ActionRowBuilder, type ModalActionRowComponentBuilder } from '../../components/ActionRow.js'; @@ -64,7 +64,7 @@ export class ModalBuilder implements JSONEncodable | APIActionRowComponent + ActionRowBuilder | APIActionRowComponent > ) { this.components.push( diff --git a/packages/discord.js/package.json b/packages/discord.js/package.json index 9ec812c7e3b5..412bac33fb17 100644 --- a/packages/discord.js/package.json +++ b/packages/discord.js/package.json @@ -72,7 +72,7 @@ "@discordjs/util": "workspace:^", "@discordjs/ws": "1.1.1", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.37.119", + "discord-api-types": "^0.38.1", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "tslib": "^2.6.3", diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 4e26514046a0..766d81e1c71d 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -111,13 +111,13 @@ import { AuditLogEvent, APIMessageComponentEmoji, EmbedType, - APIActionRowComponentTypes, + APIComponentInActionRow, APIModalInteractionResponseCallbackData, APIModalSubmitInteraction, - APIMessageActionRowComponent, + APIComponentInMessageActionRow, TextInputStyle, APITextInputComponent, - APIModalActionRowComponent, + APIComponentInModalActionRow, APIModalComponent, APISelectMenuOption, APIEmbedField, @@ -285,7 +285,7 @@ export interface BaseComponentData { } export type MessageActionRowComponentData = - | JSONEncodable + | JSONEncodable | ButtonComponentData | StringSelectMenuComponentData | UserSelectMenuComponentData @@ -293,13 +293,13 @@ export type MessageActionRowComponentData = | MentionableSelectMenuComponentData | ChannelSelectMenuComponentData; -export type ModalActionRowComponentData = JSONEncodable | TextInputComponentData; +export type ModalActionRowComponentData = JSONEncodable | TextInputComponentData; export type ActionRowComponentData = MessageActionRowComponentData | ModalActionRowComponentData; export type ActionRowComponent = MessageActionRowComponent | ModalActionRowComponent; -export interface ActionRowData | ActionRowComponentData> +export interface ActionRowData | ActionRowComponentData> extends BaseComponentData { components: readonly ComponentType[]; } @@ -309,8 +309,8 @@ export class ActionRowBuilder< > extends BuilderActionRow { public constructor( data?: Partial< - | ActionRowData> - | APIActionRowComponent + | ActionRowData> + | APIActionRowComponent >, ); public static from( @@ -330,9 +330,9 @@ export type MessageActionRowComponent = export type ModalActionRowComponent = TextInputComponent; export class ActionRow extends Component< - APIActionRowComponent + APIActionRowComponent > { - private constructor(data: APIActionRowComponent); + private constructor(data: APIActionRowComponent); public readonly components: ComponentType[]; public toJSON(): APIActionRowComponent>; } @@ -740,7 +740,7 @@ export class ButtonInteraction extends Mes export type AnyComponent = | APIMessageComponent | APIModalComponent - | APIActionRowComponent; + | APIActionRowComponent; export class Component { public readonly data: Readonly; @@ -2086,7 +2086,13 @@ export interface MessageCall { participants: readonly Snowflake[]; } -export type MessageComponentType = Exclude; +export type MessageComponentType = + | ComponentType.Button + | ComponentType.ChannelSelect + | ComponentType.MentionableSelect + | ComponentType.RoleSelect + | ComponentType.StringSelect + | ComponentType.UserSelect; export interface MessageCollectorOptionsParams< ComponentType extends MessageComponentType, @@ -2278,9 +2284,9 @@ export class MessageComponentInteraction e public get component(): CacheTypeReducer< Cached, MessageActionRowComponent, - Exclude>, - MessageActionRowComponent | Exclude>, - MessageActionRowComponent | Exclude> + Exclude>, + MessageActionRowComponent | Exclude>, + MessageActionRowComponent | Exclude> >; public componentType: Exclude; public customId: string; @@ -2457,7 +2463,7 @@ export interface ModalComponentData { customId: string; title: string; components: readonly ( - | JSONEncodable> + | JSONEncodable> | ActionRowData )[]; } @@ -6454,9 +6460,9 @@ export interface BaseMessageOptions { | AttachmentPayload )[]; components?: readonly ( - | JSONEncodable> + | JSONEncodable> | ActionRowData - | APIActionRowComponent + | APIActionRowComponent )[]; poll?: PollData; } diff --git a/packages/discord.js/typings/index.test-d.ts b/packages/discord.js/typings/index.test-d.ts index 02011850550a..a0271423f950 100644 --- a/packages/discord.js/typings/index.test-d.ts +++ b/packages/discord.js/typings/index.test-d.ts @@ -25,7 +25,7 @@ import { ApplicationCommandType, APIMessage, APIActionRowComponent, - APIActionRowComponentTypes, + APIComponentInActionRow, APIStringSelectComponent, APIUserSelectComponent, APIRoleSelectComponent, @@ -2347,7 +2347,7 @@ EmbedBuilder.from(embedData); declare const embedComp: Embed; EmbedBuilder.from(embedComp); -declare const actionRowData: APIActionRowComponent; +declare const actionRowData: APIActionRowComponent; ActionRowBuilder.from(actionRowData); declare const actionRowComp: ActionRow; @@ -2359,7 +2359,7 @@ declare const buttonsActionRowComp: ActionRow; expectType>(ActionRowBuilder.from(buttonsActionRowData)); expectType>(ActionRowBuilder.from(buttonsActionRowComp)); -declare const anyComponentsActionRowData: APIActionRowComponent; +declare const anyComponentsActionRowData: APIActionRowComponent; declare const anyComponentsActionRowComp: ActionRow; expectType(ActionRowBuilder.from(anyComponentsActionRowData)); diff --git a/packages/rest/package.json b/packages/rest/package.json index 38b2e070118a..8a2802456813 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -88,7 +88,7 @@ "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.37.119", + "discord-api-types": "^0.38.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.1" diff --git a/packages/rest/src/lib/utils/constants.ts b/packages/rest/src/lib/utils/constants.ts index 91375b5a6df5..eb7fc7dfbb64 100644 --- a/packages/rest/src/lib/utils/constants.ts +++ b/packages/rest/src/lib/utils/constants.ts @@ -1,5 +1,5 @@ import { getUserAgentAppendix } from '@discordjs/util'; -import { APIVersion } from 'discord-api-types/v10'; +import { APIVersion, type ImageSize } from 'discord-api-types/v10'; import { getDefaultStrategy } from '../../environment.js'; import type { RESTOptions, ResponseLike } from './types.js'; @@ -48,11 +48,10 @@ export enum RESTEvents { export const ALLOWED_EXTENSIONS = ['webp', 'png', 'jpg', 'jpeg', 'gif'] as const satisfies readonly string[]; export const ALLOWED_STICKER_EXTENSIONS = ['png', 'json', 'gif'] as const satisfies readonly string[]; -export const ALLOWED_SIZES = [16, 32, 64, 128, 256, 512, 1_024, 2_048, 4_096] as const satisfies readonly number[]; +export const ALLOWED_SIZES: readonly number[] = [16, 32, 64, 128, 256, 512, 1_024, 2_048, 4_096] satisfies ImageSize[]; export type ImageExtension = (typeof ALLOWED_EXTENSIONS)[number]; export type StickerExtension = (typeof ALLOWED_STICKER_EXTENSIONS)[number]; -export type ImageSize = (typeof ALLOWED_SIZES)[number]; export const OverwrittenMimeTypes = { // https://github.com/discordjs/discord.js/issues/8557 @@ -67,3 +66,5 @@ export const BurstHandlerMajorIdKey = 'burst'; * @internal */ export const DEPRECATION_WARNING_PREFIX = 'DeprecationWarning' as const; + +export { type ImageSize } from 'discord-api-types/v10'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f432077f05c..f83728fff4d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -680,8 +680,8 @@ importers: specifier: ^4.0.0 version: 4.0.0 discord-api-types: - specifier: ^0.37.119 - version: 0.37.119 + specifier: ^0.38.1 + version: 0.38.1 fast-deep-equal: specifier: ^3.1.3 version: 3.1.3 @@ -941,8 +941,8 @@ importers: specifier: 3.5.3 version: 3.5.3 discord-api-types: - specifier: ^0.37.119 - version: 0.37.119 + specifier: ^0.38.1 + version: 0.38.1 fast-deep-equal: specifier: 3.1.3 version: 3.1.3 @@ -1307,8 +1307,8 @@ importers: specifier: ^2.4.6 version: 2.4.6 discord-api-types: - specifier: ^0.37.119 - version: 0.37.119 + specifier: ^0.38.1 + version: 0.38.1 magic-bytes.js: specifier: ^1.10.0 version: 1.10.0 @@ -2601,12 +2601,12 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@definitelytyped/header-parser@0.2.16': - resolution: {integrity: sha512-UFsgPft5bhZn07UNGz/9ck4AhdKgLFEOmi2DNr7gXcGL89zbe3u5oVafKUT8j1HOtSBjT8ZEQsXHKlbq+wwF/Q==} + '@definitelytyped/header-parser@0.2.19': + resolution: {integrity: sha512-zu+RxQpUCgorYUQZoyyrRIn9CljL1CeM4qak3NDeMO1r7tjAkodfpAGnVzx/6JR2OUk0tAgwmZxNMSwd9LVgxw==} engines: {node: '>=18.18.0'} - '@definitelytyped/typescript-versions@0.1.6': - resolution: {integrity: sha512-gQpXFteIKrOw4ldmBZQfBrD3WobaIG1SwOr/3alXWkcYbkOWa2NRxQbiaYQ2IvYTGaZK26miJw0UOAFiuIs4gA==} + '@definitelytyped/typescript-versions@0.1.8': + resolution: {integrity: sha512-iz6q9aTwWW7CzN2g8jFQfZ955D63LA+wdIAKz4+2pCc/7kokmEHie1/jVWSczqLFOlmH+69bWQxIurryBP/sig==} engines: {node: '>=18.18.0'} '@definitelytyped/utils@0.1.8': @@ -7638,6 +7638,9 @@ packages: discord-api-types@0.37.83: resolution: {integrity: sha512-urGGYeWtWNYMKnYlZnOnDHm8fVRffQs3U0SpE8RHeiuLKb/u92APS8HoQnPTFbnXmY1vVnXjXO4dOxcAn3J+DA==} + discord-api-types@0.38.1: + resolution: {integrity: sha512-vsjsqjAuxsPhiwbPjTBeGQaDPlizFmSkU0mTzFGMgRxqCDIRBR7iTY74HacpzrDV0QtERHRKQEk1tq7drZUtHg==} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -8102,6 +8105,7 @@ packages: eslint-plugin-i@2.29.1: resolution: {integrity: sha512-ORizX37MelIWLbMyqI7hi8VJMf7A0CskMmYkB+lkCX3aF4pkGV7kwx5bSEb4qx7Yce2rAf9s34HqDRPjGRZPNQ==} engines: {node: '>=12'} + deprecated: Please migrate to the brand new `eslint-plugin-import-x` instead peerDependencies: eslint: ^7.2.0 || ^8 @@ -8226,6 +8230,7 @@ packages: eslint@8.57.0: resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true espree@10.1.0: @@ -12313,6 +12318,7 @@ packages: stream-connect@1.0.2: resolution: {integrity: sha512-68Kl+79cE0RGKemKkhxTSg8+6AGrqBt+cbZAXevg2iJ6Y3zX4JhA/sZeGzLpxW9cXhmqAcE7KnJCisUmIUfnFQ==} engines: {node: '>=0.10.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. stream-to-array@2.3.0: resolution: {integrity: sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==} @@ -13860,7 +13866,7 @@ snapshots: '@babel/helper-plugin-utils': 7.24.8 debug: 4.4.0 lodash.debounce: 4.0.8 - resolve: 1.22.8 + resolve: 1.22.10 transitivePeerDependencies: - supports-color @@ -14609,7 +14615,7 @@ snapshots: '@babel/parser': 7.25.4 '@babel/template': 7.25.0 '@babel/types': 7.25.4 - debug: 4.3.6 + debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -14837,7 +14843,7 @@ snapshots: '@conventional-changelog/git-client@1.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.0.0)': dependencies: '@types/semver': 7.5.8 - semver: 7.6.3 + semver: 7.5.4 optionalDependencies: conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.0.0 @@ -14846,13 +14852,13 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@definitelytyped/header-parser@0.2.16': + '@definitelytyped/header-parser@0.2.19': dependencies: - '@definitelytyped/typescript-versions': 0.1.6 + '@definitelytyped/typescript-versions': 0.1.8 '@definitelytyped/utils': 0.1.8 semver: 7.6.3 - '@definitelytyped/typescript-versions@0.1.6': {} + '@definitelytyped/typescript-versions@0.1.8': {} '@definitelytyped/utils@0.1.8': dependencies: @@ -15309,7 +15315,7 @@ snapshots: '@antfu/install-pkg': 0.4.0 '@antfu/utils': 0.7.10 '@iconify/types': 2.0.0 - debug: 4.3.6 + debug: 4.4.0 kolorist: 1.8.0 local-pkg: 0.5.0 mlly: 1.7.1 @@ -15432,7 +15438,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 18.19.45 + '@types/node': 18.19.74 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -15512,7 +15518,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 18.19.45 + '@types/node': 18.19.74 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -15530,7 +15536,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 18.19.45 + '@types/node': 18.19.74 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -15552,7 +15558,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.25 - '@types/node': 18.19.45 + '@types/node': 18.19.74 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -15814,7 +15820,7 @@ snapshots: '@rushstack/ts-command-line': 4.19.1(@types/node@16.18.105) lodash: 4.17.21 minimatch: 3.0.8 - resolve: 1.22.8 + resolve: 1.22.10 semver: 7.5.4 source-map: 0.6.1 typescript: 5.4.2 @@ -15833,7 +15839,7 @@ snapshots: '@rushstack/ts-command-line': 4.19.1(@types/node@18.17.9) lodash: 4.17.21 minimatch: 3.0.8 - resolve: 1.22.8 + resolve: 1.22.10 semver: 7.5.4 source-map: 0.6.1 typescript: 5.4.2 @@ -15852,7 +15858,7 @@ snapshots: '@rushstack/ts-command-line': 4.19.1(@types/node@18.19.45) lodash: 4.17.21 minimatch: 3.0.8 - resolve: 1.22.8 + resolve: 1.22.10 semver: 7.5.4 source-map: 0.6.1 typescript: 5.4.2 @@ -15870,7 +15876,7 @@ snapshots: '@rushstack/ts-command-line': 4.19.1(@types/node@20.16.1) lodash: 4.17.21 minimatch: 3.0.8 - resolve: 1.22.8 + resolve: 1.22.10 semver: 7.5.4 source-map: 0.6.1 typescript: 5.4.2 @@ -17995,7 +18001,7 @@ snapshots: fs-extra: 7.0.1 import-lazy: 4.0.0 jju: 1.4.0 - resolve: 1.22.8 + resolve: 1.22.10 semver: 7.5.4 z-schema: 5.0.5 optionalDependencies: @@ -18007,7 +18013,7 @@ snapshots: fs-extra: 7.0.1 import-lazy: 4.0.0 jju: 1.4.0 - resolve: 1.22.8 + resolve: 1.22.10 semver: 7.5.4 z-schema: 5.0.5 optionalDependencies: @@ -18019,7 +18025,7 @@ snapshots: fs-extra: 7.0.1 import-lazy: 4.0.0 jju: 1.4.0 - resolve: 1.22.8 + resolve: 1.22.10 semver: 7.5.4 z-schema: 5.0.5 optionalDependencies: @@ -18030,7 +18036,7 @@ snapshots: fs-extra: 7.0.1 import-lazy: 4.0.0 jju: 1.4.0 - resolve: 1.22.8 + resolve: 1.22.10 semver: 7.5.4 z-schema: 5.0.5 optionalDependencies: @@ -18055,7 +18061,7 @@ snapshots: '@rushstack/rig-package@0.5.2': dependencies: - resolve: 1.22.8 + resolve: 1.22.10 strip-json-comments: 3.1.1 '@rushstack/terminal@0.10.0(@types/node@16.18.105)': @@ -18919,25 +18925,25 @@ snapshots: '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 - '@types/node': 18.19.45 + '@types/node': 18.19.74 '@types/concat-stream@2.0.3': dependencies: - '@types/node': 18.19.45 + '@types/node': 18.19.74 '@types/connect@3.4.38': dependencies: - '@types/node': 18.19.45 + '@types/node': 18.19.74 '@types/conventional-commits-parser@5.0.0': dependencies: - '@types/node': 18.19.45 + '@types/node': 18.19.74 '@types/cookiejar@2.1.5': {} '@types/cross-spawn@6.0.6': dependencies: - '@types/node': 18.19.45 + '@types/node': 18.19.74 '@types/debug@4.1.12': dependencies: @@ -18969,7 +18975,7 @@ snapshots: '@types/express-serve-static-core@4.19.5': dependencies: - '@types/node': 18.19.45 + '@types/node': 18.19.74 '@types/qs': 6.9.15 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 @@ -18986,11 +18992,11 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 18.19.45 + '@types/node': 18.19.74 '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 18.19.45 + '@types/node': 18.19.74 '@types/hast@2.3.10': dependencies: @@ -19072,7 +19078,7 @@ snapshots: '@types/node-fetch@2.6.11': dependencies: - '@types/node': 18.19.45 + '@types/node': 18.19.74 form-data: 4.0.0 '@types/node@16.18.105': {} @@ -19099,7 +19105,7 @@ snapshots: '@types/pg@8.11.6': dependencies: - '@types/node': 18.19.45 + '@types/node': 18.19.74 pg-protocol: 1.6.1 pg-types: 4.0.2 @@ -19132,12 +19138,12 @@ snapshots: '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 18.19.45 + '@types/node': 18.19.74 '@types/serve-static@1.15.7': dependencies: '@types/http-errors': 2.0.4 - '@types/node': 18.19.45 + '@types/node': 18.19.74 '@types/send': 0.17.4 '@types/stack-utils@2.0.3': {} @@ -19158,7 +19164,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 18.19.45 + '@types/node': 18.19.74 '@types/tinycolor2@1.4.6': {} @@ -19278,7 +19284,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.11.0(typescript@5.5.4) '@typescript-eslint/utils': 7.11.0(eslint@8.57.0)(typescript@5.5.4) - debug: 4.3.6 + debug: 4.4.0 eslint: 8.57.0 ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: @@ -19290,7 +19296,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) '@typescript-eslint/utils': 7.18.0(eslint@8.57.0)(typescript@5.5.4) - debug: 4.3.6 + debug: 4.4.0 eslint: 8.57.0 ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: @@ -19302,7 +19308,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.2.0(typescript@5.5.4) '@typescript-eslint/utils': 8.2.0(eslint@8.57.0)(typescript@5.5.4) - debug: 4.3.6 + debug: 4.4.0 ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: typescript: 5.5.4 @@ -19351,7 +19357,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.6 + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -19366,7 +19372,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.2.0 '@typescript-eslint/visitor-keys': 8.2.0 - debug: 4.3.6 + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -21509,6 +21515,8 @@ snapshots: discord-api-types@0.37.83: {} + discord-api-types@0.38.1: {} + dlv@1.1.3: {} dmd@6.2.3: @@ -21558,7 +21566,7 @@ snapshots: dts-critic@3.3.11(typescript@5.5.4): dependencies: - '@definitelytyped/header-parser': 0.2.16 + '@definitelytyped/header-parser': 0.2.19 command-exists: 1.2.9 rimraf: 3.0.2 semver: 6.3.1 @@ -21568,8 +21576,8 @@ snapshots: dtslint@4.2.1(typescript@5.5.4): dependencies: - '@definitelytyped/header-parser': 0.2.16 - '@definitelytyped/typescript-versions': 0.1.6 + '@definitelytyped/header-parser': 0.2.19 + '@definitelytyped/typescript-versions': 0.1.8 '@definitelytyped/utils': 0.1.8 dts-critic: 3.3.11(typescript@5.5.4) fs-extra: 6.0.1 @@ -23913,7 +23921,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 18.19.45 + '@types/node': 18.19.74 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.3 @@ -24088,7 +24096,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 18.19.45 + '@types/node': 18.19.74 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -24098,7 +24106,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 18.19.45 + '@types/node': 18.19.74 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -24137,7 +24145,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 18.19.45 + '@types/node': 18.19.74 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -24161,7 +24169,7 @@ snapshots: jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) jest-util: 29.7.0 jest-validate: 29.7.0 - resolve: 1.22.8 + resolve: 1.22.10 resolve.exports: 2.0.2 slash: 3.0.0 @@ -24172,7 +24180,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 18.19.45 + '@types/node': 18.19.74 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -24200,7 +24208,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 18.19.45 + '@types/node': 18.19.74 chalk: 4.1.2 cjs-module-lexer: 1.3.1 collect-v8-coverage: 1.0.2 @@ -24246,7 +24254,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 18.19.45 + '@types/node': 18.19.74 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -24265,7 +24273,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 18.19.45 + '@types/node': 18.19.74 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -24279,7 +24287,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 18.19.45 + '@types/node': 18.19.74 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -26065,7 +26073,7 @@ snapshots: normalize-package-data@3.0.3: dependencies: hosted-git-info: 4.1.0 - is-core-module: 2.15.1 + is-core-module: 2.16.1 semver: 7.5.4 validate-npm-package-license: 3.0.4 @@ -26753,7 +26761,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 18.19.45 + '@types/node': 18.19.74 long: 5.2.3 proxy-addr@2.0.7: @@ -26764,7 +26772,7 @@ snapshots: proxy-agent@6.4.0: dependencies: agent-base: 7.1.1 - debug: 4.3.6 + debug: 4.4.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.5 lru-cache: 7.18.3 @@ -26936,7 +26944,7 @@ snapshots: '@types/doctrine': 0.0.9 '@types/resolve': 1.20.6 doctrine: 3.0.0 - resolve: 1.22.8 + resolve: 1.22.10 strip-indent: 4.0.0 transitivePeerDependencies: - supports-color @@ -27435,7 +27443,7 @@ snapshots: resolve@2.0.0-next.5: dependencies: - is-core-module: 2.15.1 + is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -28088,7 +28096,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.3.6 + debug: 4.4.0 fast-safe-stringify: 2.1.1 form-data: 4.0.0 formidable: 3.5.1 @@ -28832,7 +28840,7 @@ snapshots: '@types/node': 20.16.1 '@types/unist': 3.0.3 concat-stream: 2.0.0 - debug: 4.3.6 + debug: 4.4.0 extend: 3.0.2 glob: 10.4.5 ignore: 5.3.2