Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 85 additions & 2 deletions packages/builders/__tests__/messages/message.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AllowedMentionsTypes, MessageFlags } from 'discord-api-types/v10';
import { AllowedMentionsTypes, BaseThemeType, MessageFlags } from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import { AllowedMentionsBuilder, EmbedBuilder, MessageBuilder } from '../../src/index.js';
import { AllowedMentionsBuilder, EmbedBuilder, MessageBuilder, SharedClientThemeBuilder } from '../../src/index.js';

const base = {
allowed_mentions: undefined,
Expand All @@ -9,6 +9,7 @@ const base = {
embeds: [],
message_reference: undefined,
poll: undefined,
shared_client_theme: undefined,
};

describe('Message', () => {
Expand Down Expand Up @@ -103,6 +104,88 @@ describe('Message', () => {
question: { text: 'foo' },
answers: [{ poll_media: { text: 'foo' } }],
},
shared_client_theme: undefined,
});
});

describe('SharedClientTheme', () => {
test('GIVEN a message with a shared client theme THEN return valid toJSON data', () => {
const message = new MessageBuilder().setSharedClientTheme(
new SharedClientThemeBuilder()
.setColors(['5865F2', '7258F2'])
.setGradientAngle(0)
.setBaseMix(58)
.setBaseTheme(BaseThemeType.Dark),
);

expect(message.toJSON()).toStrictEqual({
...base,
shared_client_theme: {
colors: ['5865F2', '7258F2'],
gradient_angle: 0,
base_mix: 58,
base_theme: 1,
},
});
});

test('GIVEN a message with a function to update shared client theme THEN return valid toJSON data', () => {
const message = new MessageBuilder().updateSharedClientTheme((theme) =>
theme.setColors(['5865F2']).setGradientAngle(90).setBaseMix(100),
);

expect(message.toJSON()).toStrictEqual({
...base,
shared_client_theme: {
colors: ['5865F2'],
gradient_angle: 90,
base_mix: 100,
base_theme: undefined,
},
});
});

test('GIVEN a message with a shared client theme then cleared THEN shared_client_theme is undefined', () => {
const message = new MessageBuilder()
.setSharedClientTheme(new SharedClientThemeBuilder().setColors(['5865F2']).setGradientAngle(0).setBaseMix(50))
.clearSharedClientTheme();

expect(message.toJSON()).toStrictEqual(base);
});

test('GIVEN a SharedClientThemeBuilder with too many colors THEN it throws', () => {
const theme = new SharedClientThemeBuilder()
.setColors(['111111', '222222', '333333', '444444', '555555', '666666'])
.setGradientAngle(0)
.setBaseMix(50);

expect(() => theme.toJSON()).toThrow();
});

test('GIVEN a SharedClientThemeBuilder with out of range gradient angle THEN it throws', () => {
const theme = new SharedClientThemeBuilder().setColors(['5865F2']).setGradientAngle(400).setBaseMix(50);
expect(() => theme.toJSON()).toThrow();
});

test('GIVEN a SharedClientThemeBuilder with out of range base mix THEN it throws', () => {
const theme = new SharedClientThemeBuilder().setColors(['5865F2']).setGradientAngle(0).setBaseMix(150);
expect(() => theme.toJSON()).toThrow();
});

test('GIVEN a shared client theme with base_theme set THEN clearBaseTheme works correctly', () => {
const theme = new SharedClientThemeBuilder()
.setColors(['5865F2'])
.setGradientAngle(0)
.setBaseMix(50)
.setBaseTheme(BaseThemeType.Light)
.clearBaseTheme();

expect(theme.toJSON(false)).toStrictEqual({
colors: ['5865F2'],
gradient_angle: 0,
base_mix: 50,
base_theme: undefined,
});
});
});
});
1 change: 1 addition & 0 deletions packages/builders/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export * from './messages/Assertions.js';
export * from './messages/Attachment.js';
export * from './messages/Message.js';
export * from './messages/MessageReference.js';
export * from './messages/SharedClientTheme.js';

export * from './util/normalizeArray.js';
export * from './util/resolveBuilder.js';
Expand Down
16 changes: 13 additions & 3 deletions packages/builders/src/messages/Assertions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Buffer } from 'node:buffer';
import { AllowedMentionsTypes, ComponentType, MessageFlags, MessageReferenceType } from 'discord-api-types/v10';
import { AllowedMentionsTypes, BaseThemeType, ComponentType, MessageFlags, MessageReferenceType } from 'discord-api-types/v10';
import { z } from 'zod';
import { snowflakePredicate } from '../Assertions.js';
import { embedPredicate } from './embed/Assertions.js';
Expand Down Expand Up @@ -79,12 +79,20 @@ const basicActionRowPredicate = z.object({
.array(),
});

export const sharedClientThemePredicate = z.object({
colors: z.array(z.string().regex(/^[\da-f]{6}$/i)).min(1).max(5),
gradient_angle: z.int().min(0).max(360),
base_mix: z.int().min(0).max(100),
base_theme: z.enum(BaseThemeType).nullish(),
});

const messageNoComponentsV2Predicate = baseMessagePredicate
.extend({
content: z.string().max(2_000).optional(),
embeds: embedPredicate.array().max(10).optional(),
sticker_ids: z.array(z.string()).max(3).optional(),
poll: pollPredicate.optional(),
shared_client_theme: sharedClientThemePredicate.nullish(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
shared_client_theme: sharedClientThemePredicate.nullish(),
shared_client_theme: sharedClientThemePredicate.optional(),

shared_client_theme is not nullable.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is nullable in the spec

components: basicActionRowPredicate.array().max(5).optional(),
flags: z
.int()
Expand All @@ -100,8 +108,9 @@ const messageNoComponentsV2Predicate = baseMessagePredicate
data.poll !== undefined ||
(data.attachments !== undefined && data.attachments.length > 0) ||
(data.components !== undefined && data.components.length > 0) ||
(data.sticker_ids !== undefined && data.sticker_ids.length > 0),
{ error: 'Messages must have content, embeds, a poll, attachments, components or stickers' },
(data.sticker_ids !== undefined && data.sticker_ids.length > 0) ||
data.shared_client_theme !== undefined,
{ error: 'Messages must have content, embeds, a poll, attachments, components, stickers, or a shared client theme' },
);

const allTopLevelComponentsPredicate = z
Expand Down Expand Up @@ -134,6 +143,7 @@ const messageComponentsV2Predicate = baseMessagePredicate.extend({
embeds: z.array(z.never()).nullish(),
sticker_ids: z.array(z.never()).nullish(),
poll: z.null().optional(),
shared_client_theme: z.null().optional(),
});

export const messagePredicate = z.union([messageNoComponentsV2Predicate, messageComponentsV2Predicate]);
Expand Down
44 changes: 41 additions & 3 deletions packages/builders/src/messages/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
APISeparatorComponent,
APITextDisplayComponent,
APIMessageTopLevelComponent,
APIMessageSharedClientTheme
} from 'discord-api-types/v10';
import { ActionRowBuilder } from '../components/ActionRow.js';
import { ComponentBuilder } from '../components/Component.js';
Expand All @@ -35,13 +36,14 @@ import { AllowedMentionsBuilder } from './AllowedMentions.js';
import { fileBodyMessagePredicate, messagePredicate } from './Assertions.js';
import { AttachmentBuilder } from './Attachment.js';
import { MessageReferenceBuilder } from './MessageReference.js';
import { SharedClientThemeBuilder } from './SharedClientTheme.js';
import { EmbedBuilder } from './embed/Embed.js';
import { PollBuilder } from './poll/Poll.js';

export interface MessageBuilderData extends Partial<
Omit<
RESTPostAPIChannelMessageJSONBody,
'allowed_mentions' | 'attachments' | 'components' | 'embeds' | 'message_reference' | 'poll'
'allowed_mentions' | 'attachments' | 'components' | 'embeds' | 'message_reference' | 'poll' | 'shared_client_theme'
>
> {
allowed_mentions?: AllowedMentionsBuilder;
Expand All @@ -50,6 +52,7 @@ export interface MessageBuilderData extends Partial<
embeds: EmbedBuilder[];
message_reference?: MessageReferenceBuilder;
poll?: PollBuilder;
shared_client_theme?: SharedClientThemeBuilder;
}

/**
Expand Down Expand Up @@ -90,7 +93,7 @@ export class MessageBuilder
* @param data - The API data to create this message with
*/
public constructor(data: Partial<RESTPostAPIChannelMessageJSONBody> = {}) {
const { attachments = [], embeds = [], components = [], message_reference, poll, allowed_mentions, ...rest } = data;
const { attachments = [], embeds = [], components = [], message_reference, poll, allowed_mentions, shared_client_theme, ...rest } = data;

this.data = {
...structuredClone(rest),
Expand All @@ -100,6 +103,7 @@ export class MessageBuilder
poll: poll && new PollBuilder(poll),
components: components.map((component) => createComponentBuilder(component)),
message_reference: message_reference && new MessageReferenceBuilder(message_reference),
shared_client_theme: shared_client_theme && new SharedClientThemeBuilder(shared_client_theme),
};
}

Expand Down Expand Up @@ -636,6 +640,39 @@ export class MessageBuilder
return this;
}

/**
* Sets the shared client theme for this message.
*
* @param theme - The shared client theme to set
*/
public setSharedClientTheme(
theme:
| APIMessageSharedClientTheme
| SharedClientThemeBuilder
| ((builder: SharedClientThemeBuilder) => SharedClientThemeBuilder),
): this {
this.data.shared_client_theme = resolveBuilder(theme, SharedClientThemeBuilder);
return this;
}

/**
* Updates the shared client theme for this message (and creates it if it doesn't exist).
*
* @param updater - The function to update the shared client theme with
*/
public updateSharedClientTheme(updater: (builder: SharedClientThemeBuilder) => void): this {
updater((this.data.shared_client_theme ??= new SharedClientThemeBuilder()));
return this;
}

/**
* Clears the shared client theme for this message.
*/
public clearSharedClientTheme(): this {
this.data.shared_client_theme = undefined;
return this;
}

/**
* Serializes this builder to API-compatible JSON data.
*
Expand All @@ -644,7 +681,7 @@ export class MessageBuilder
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public toJSON(validationOverride?: boolean): RESTPostAPIChannelMessageJSONBody {
const { poll, allowed_mentions, attachments, embeds, components, message_reference, ...rest } = this.data;
const { poll, allowed_mentions, attachments, embeds, components, message_reference, shared_client_theme, ...rest } = this.data;

const data = {
...structuredClone(rest),
Expand All @@ -656,6 +693,7 @@ export class MessageBuilder
// Here, the messagePredicate does specific constraints rather than using the componentPredicate
components: components.map((component) => component.toJSON(validationOverride)),
message_reference: message_reference?.toJSON(false),
shared_client_theme: shared_client_theme?.toJSON(false),
};

validate(messagePredicate, data, validationOverride);
Expand Down
92 changes: 92 additions & 0 deletions packages/builders/src/messages/SharedClientTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { JSONEncodable } from '@discordjs/util';
import type { APIMessageSharedClientTheme, BaseThemeType } from 'discord-api-types/v10';
import { validate } from '../util/validation.js';
import { sharedClientThemePredicate } from './Assertions.js';

/**
* A builder that creates API-compatible JSON data for shared client themes.
*
* @see {@link https://discord.com/developers/docs/resources/message#shared-client-theme-object}
*/
export class SharedClientThemeBuilder implements JSONEncodable<APIMessageSharedClientTheme> {
/**
* The API data associated with this shared client theme.
*/
private readonly data: Partial<APIMessageSharedClientTheme>;

/**
* Creates a new shared client theme builder.
*
* @param data - The API data to create this shared client theme with
*/
public constructor(data: Partial<APIMessageSharedClientTheme> = {}) {
this.data = structuredClone(data);
}

/**
* Sets the colors of this theme.
*
* @remarks
* A maximum of 5 hexadecimal-encoded colors may be provided.
* @param colors - The hexadecimal-encoded colors to set (e.g. `'5865F2'`)
*/
public setColors(colors: readonly string[]): this {
this.data.colors = [...colors];
return this;
}

/**
* Sets the gradient angle of this theme.
*
* @remarks
* The value must be between `0` and `360` (inclusive).
* @param angle - The gradient angle (direction of theme colors)
*/
public setGradientAngle(angle: number): this {
this.data.gradient_angle = angle;
return this;
}

/**
* Sets the base mix (intensity) of this theme.
*
* @remarks
* The value must be between `0` and `100` (inclusive).
* @param baseMix - The base mix intensity
*/
public setBaseMix(baseMix: number): this {
this.data.base_mix = baseMix;
return this;
}

/**
* Sets the base theme (mode) of this theme.
*
* @param baseTheme - The base theme mode, or `null` to clear
*/
public setBaseTheme(baseTheme: BaseThemeType | null): this {
this.data.base_theme = baseTheme;
return this;
}

/**
* Clears the base theme of this theme.
*/
public clearBaseTheme(): this {
this.data.base_theme = undefined;
return this;
}

/**
* Serializes this builder to API-compatible JSON data.
*
* Note that by disabling validation, there is no guarantee that the resulting object will be valid.
*
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public toJSON(validationOverride?: boolean): APIMessageSharedClientTheme {
const data = structuredClone(this.data);
validate(sharedClientThemePredicate, data, validationOverride);
return data as APIMessageSharedClientTheme;
}
}
28 changes: 28 additions & 0 deletions packages/discord.js/src/structures/Message.js
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,34 @@ class Message extends Base {
} else {
this.call ??= null;
}

/**
* The shared client theme sent with this message
*
* @typedef {Object} MessageSharedClientTheme
* @property {string[]} colors The hexadecimal-encoded colors of the theme (max of 5)
* @property {number} gradientAngle The direction of the theme's colors (0–360)
* @property {number} baseMix The intensity of the theme's colors (0–100)
* @property {?BaseThemeType} [baseTheme] The mode of the theme
*/
if (data.shared_client_theme) {
/**
* The shared client theme sent with this message
*
* @type {?MessageSharedClientTheme}
*/
this.sharedClientTheme = {
colors: data.shared_client_theme.colors,
gradientAngle: data.shared_client_theme.gradient_angle,
baseMix: data.shared_client_theme.base_mix,
};

if ('base_theme' in data.shared_client_theme) {
this.sharedClientTheme.baseTheme = data.shared_client_theme.baseTheme;
}
} else {
this.sharedClientTheme ??= null;
}
}

/**
Expand Down
Loading
Loading