feat: add shared client theme support (#11454)

* feat: add shared client theme support

* Apply suggestion from @Qjuh

Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>

* chore: tests

* chore: format

* chore: apply suggestions from code review

---------

Co-authored-by: Almeida <github@almeidx.dev>
Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Kshitij Anurag
2026-05-23 04:01:00 +05:30
committed by GitHub
parent e490a230a3
commit 28dd65d322
13 changed files with 404 additions and 10 deletions

View File

@@ -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,
@@ -9,6 +9,7 @@ const base = {
embeds: [],
message_reference: undefined,
poll: undefined,
shared_client_theme: undefined,
};
describe('Message', () => {
@@ -103,6 +104,92 @@ 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,
},
});
});
test('GIVEN a message with a shared client theme then cleared THEN shared_client_theme is undefined', () => {
const message = new MessageBuilder()
.setContent('foo')
.setSharedClientTheme(new SharedClientThemeBuilder().setColors(['5865F2']).setGradientAngle(0).setBaseMix(50))
.clearSharedClientTheme();
expect(message.toJSON()).toStrictEqual({
...base,
content: 'foo',
shared_client_theme: undefined,
});
});
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,
});
});
});
});

View File

@@ -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';

View File

@@ -1,5 +1,11 @@
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';
@@ -79,18 +85,29 @@ 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.optional(),
components: basicActionRowPredicate.array().max(5).optional(),
flags: z
.int()
.optional()
.refine((flags) => !flags || (flags & MessageFlags.IsComponentsV2) === 0, {
error: 'Cannot set content, embeds, stickers, or poll with IsComponentsV2 flag set',
error: 'Cannot set content, embeds, stickers, poll, or shared client theme with IsComponentsV2 flag set',
}),
})
.refine(
@@ -100,8 +117,11 @@ 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
@@ -134,6 +154,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]);

View File

@@ -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';
@@ -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;
@@ -50,6 +52,7 @@ export interface MessageBuilderData extends Partial<
embeds: EmbedBuilder[];
message_reference?: MessageReferenceBuilder;
poll?: PollBuilder;
shared_client_theme?: SharedClientThemeBuilder;
}
/**
@@ -90,7 +93,16 @@ 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),
@@ -100,6 +112,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),
};
}
@@ -636,6 +649,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.
*
@@ -644,7 +690,8 @@ 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),
@@ -656,6 +703,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);

View File

@@ -0,0 +1,91 @@
import type { JSONEncodable } from '@discordjs/util';
import type { APIMessageSharedClientTheme, BaseThemeType } from 'discord-api-types/v10';
import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js';
import { validate } from '../util/validation.js';
import { sharedClientThemePredicate } from './Assertions.js';
/**
* A builder that creates API-compatible JSON data for shared client themes.
*/
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: RestOrArray<string>): this {
this.data.colors = normalizeArray(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
*/
public setBaseTheme(baseTheme: BaseThemeType): 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;
}
}

View File

@@ -499,6 +499,34 @@ class Message extends Base {
} else {
this.call ??= null;
}
/**
* The shared client theme sent with this message
*
* @typedef {Object} SharedClientTheme
* @property {string[]} colors The hexadecimal-encoded colors of the theme (max of 5)
* @property {number} gradientAngle The direction of the theme's colors (0360)
* @property {number} baseMix The intensity of the theme's colors (0100)
* @property {?BaseThemeType} [baseTheme] The mode of the theme
*/
if (data.shared_client_theme) {
/**
* The shared client theme sent with this message
*
* @type {?SharedClientTheme}
*/
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.base_theme;
}
} else {
this.sharedClientTheme ??= null;
}
}
/**

View File

@@ -231,6 +231,18 @@ class MessagePayload {
};
}
let shared_client_theme;
if (this.options.sharedClientTheme) {
shared_client_theme = isJSONEncodable(this.options.sharedClientTheme)
? this.options.sharedClientTheme.toJSON()
: {
colors: this.options.sharedClientTheme.colors,
gradient_angle: this.options.sharedClientTheme.gradientAngle,
base_mix: this.options.sharedClientTheme.baseMix,
base_theme: this.options.sharedClientTheme.baseTheme,
};
}
this.body = {
content,
tts,
@@ -253,6 +265,7 @@ class MessagePayload {
thread_name: threadName,
applied_tags: appliedTags,
poll,
shared_client_theme,
};
return this;
}

View File

@@ -340,6 +340,11 @@
* @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/AuditLogEvent}
*/
/**
* @external BaseThemeType
* @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/BaseThemeType}
*/
/**
* @external ButtonStyle
* @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/ButtonStyle}

View File

@@ -61,6 +61,7 @@ import {
APIMessageComponentInteraction,
APIMessageMentionableSelectInteractionData,
APIMessageRoleSelectInteractionData,
APIMessageSharedClientTheme,
APIMessageStringSelectInteractionData,
APIMessageTopLevelComponent,
APIMessageUserSelectInteractionData,
@@ -115,6 +116,7 @@ import {
AutoModerationRuleEventType,
AutoModerationRuleKeywordPresetType,
AutoModerationRuleTriggerType,
BaseThemeType,
ButtonStyle,
ChannelFlags,
ChannelType,
@@ -2129,6 +2131,13 @@ export interface MessageCall {
participants: readonly Snowflake[];
}
export interface SharedClientTheme {
baseMix: number;
baseTheme?: BaseThemeType | null;
colors: readonly string[];
gradientAngle: number;
}
export type MessageComponentType =
| ComponentType.Button
| ComponentType.ChannelSelect
@@ -2224,6 +2233,7 @@ export class Message<InGuild extends boolean = boolean> extends Base {
public tts: boolean;
public poll: Poll | null;
public call: MessageCall | null;
public sharedClientTheme: SharedClientTheme | null;
public type: MessageType;
public get url(): string;
public webhookId: Snowflake | null;
@@ -6790,6 +6800,7 @@ export interface BaseMessageCreateOptions
extends BaseMessageSendOptions, MessageOptionsPoll, MessageOptionsFlags, MessageOptionsTTS, MessageOptionsStickers {
enforceNonce?: boolean;
nonce?: number | string;
sharedClientTheme?: JSONEncodable<APIMessageSharedClientTheme> | SharedClientTheme;
}
export interface MessageCreateOptions extends BaseMessageCreateOptions {

View File

@@ -16,6 +16,7 @@ import type {
APIUserSelectComponent,
} from 'discord-api-types/v10';
import {
BaseThemeType,
MessageReferenceType,
MessageType,
MessageFlags,
@@ -28,6 +29,7 @@ import {
import { describe, expect, test } from 'vitest';
import { Attachment } from '../src/messages/Attachment.js';
import { Message } from '../src/messages/Message.js';
import { SharedClientTheme } from '../src/messages/SharedClientTheme.js';
import { ContainerComponent } from '../src/messages/components/ContainerComponent.js';
import { Embed } from '../src/messages/embeds/Embed.js';
import { User } from '../src/users/User.js';
@@ -476,3 +478,32 @@ describe('message with components', () => {
expect(containerInstance.spoiler).toBe(container.spoiler);
});
});
describe('SharedClientTheme structure', () => {
const rawTheme = {
colors: ['5865F2', '7258F2', '9858F2'],
gradient_angle: 45,
base_mix: 58,
base_theme: BaseThemeType.Dark,
};
test('GIVEN a shared client theme THEN exposes all getters correctly', () => {
const instance = new SharedClientTheme(rawTheme);
expect(instance.colors).toStrictEqual(rawTheme.colors);
expect(instance.gradientAngle).toBe(rawTheme.gradient_angle);
expect(instance.baseMix).toBe(rawTheme.base_mix);
expect(instance.baseTheme).toBe(BaseThemeType.Dark);
expect(instance.toJSON()).toEqual(rawTheme);
});
test('GIVEN a shared client theme without base_theme THEN baseTheme is undefined', () => {
const { base_theme: _, ...withoutTheme } = rawTheme;
const instance = new SharedClientTheme(withoutTheme);
expect(instance.baseTheme).toBeUndefined();
});
test('GIVEN a shared client theme with null base_theme THEN baseTheme is null', () => {
const instance = new SharedClientTheme({ ...rawTheme, base_theme: null });
expect(instance.baseTheme).toBeNull();
});
});

View File

@@ -13,7 +13,7 @@ import type { Partialize } from '../utils/types.js';
* Represents a message on Discord.
*
* @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate`
* @remarks has substructures `Message`, `Channel`, `MessageActivity`, `MessageCall`, `MessageReference`, `Attachment`, `Application`, `ChannelMention`, `Reaction`, `Poll`, `ResolvedInteractionData`, `RoleSubscriptionData`, `Sticker`, all the different `Component`s, ... which need to be instantiated and stored by an extending class using it
* @remarks has substructures `Message`, `Channel`, `MessageActivity`, `MessageCall`, `MessageReference`, `SharedClientTheme`, `Attachment`, `Application`, `ChannelMention`, `Reaction`, `Poll`, `ResolvedInteractionData`, `RoleSubscriptionData`, `Sticker`, all the different `Component`s, ... which need to be instantiated and stored by an extending class using it
*/
export class Message<Omitted extends keyof APIMessage | '' = 'edited_timestamp' | 'timestamp'> extends Structure<
APIMessage,

View File

@@ -0,0 +1,57 @@
import type { APIMessageSharedClientTheme } from 'discord-api-types/v10';
import { Structure } from '../Structure.js';
import { kData } from '../utils/symbols.js';
import type { Partialize } from '../utils/types.js';
/**
* Represents the shared client theme sent with a Discord message.
*
* @typeParam Omitted - Specify the properties that will not be stored in the raw data field as a union, implement via `DataTemplate`
* @see {@link https://docs.discord.com/developers/resources/message#shared-client-theme-object}
*/
export class SharedClientTheme<Omitted extends keyof APIMessageSharedClientTheme | '' = ''> extends Structure<
APIMessageSharedClientTheme,
Omitted
> {
/**
* The template used for removing data from the raw data stored for each SharedClientTheme.
*/
public static override DataTemplate: Partial<APIMessageSharedClientTheme> = {};
/**
* @param data - The raw data received from the API for the shared client theme
*/
public constructor(data: Partialize<APIMessageSharedClientTheme, Omitted>) {
super(data);
}
/**
* The hexadecimal-encoded colors of this theme (max of 5)
*/
public get colors() {
return this[kData].colors;
}
/**
* The gradient angle (direction) of this theme's colors (0360)
*/
public get gradientAngle() {
return this[kData].gradient_angle;
}
/**
* The base mix (intensity) of this theme's colors (0100)
*/
public get baseMix() {
return this[kData].base_mix;
}
/**
* The base theme mode
*
* @see {@link https://docs.discord.com/developers/resources/message#base-theme-types}
*/
public get baseTheme() {
return this[kData].base_theme;
}
}

View File

@@ -14,3 +14,4 @@ export * from './ModalSubmitInteractionMetadata.js';
export * from './Reaction.js';
export * from './ReactionCountDetails.js';
export * from './RoleSubscriptionData.js';
export * from './SharedClientTheme.js';