Skip to content

Commit 1a691fe

Browse files
committed
feat: add shared client theme support
1 parent 2a06721 commit 1a691fe

File tree

13 files changed

+377
-10
lines changed

13 files changed

+377
-10
lines changed

packages/builders/__tests__/messages/message.test.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { AllowedMentionsTypes, MessageFlags } from 'discord-api-types/v10';
1+
import { AllowedMentionsTypes, BaseThemeType, MessageFlags } from 'discord-api-types/v10';
22
import { describe, test, expect } from 'vitest';
3-
import { AllowedMentionsBuilder, EmbedBuilder, MessageBuilder } from '../../src/index.js';
3+
import { AllowedMentionsBuilder, EmbedBuilder, MessageBuilder, SharedClientThemeBuilder } from '../../src/index.js';
44

55
const base = {
66
allowed_mentions: undefined,
@@ -9,6 +9,7 @@ const base = {
99
embeds: [],
1010
message_reference: undefined,
1111
poll: undefined,
12+
shared_client_theme: undefined,
1213
};
1314

1415
describe('Message', () => {
@@ -103,6 +104,88 @@ describe('Message', () => {
103104
question: { text: 'foo' },
104105
answers: [{ poll_media: { text: 'foo' } }],
105106
},
107+
shared_client_theme: undefined,
108+
});
109+
});
110+
111+
describe('SharedClientTheme', () => {
112+
test('GIVEN a message with a shared client theme THEN return valid toJSON data', () => {
113+
const message = new MessageBuilder().setSharedClientTheme(
114+
new SharedClientThemeBuilder()
115+
.setColors(['5865F2', '7258F2'])
116+
.setGradientAngle(0)
117+
.setBaseMix(58)
118+
.setBaseTheme(BaseThemeType.Dark),
119+
);
120+
121+
expect(message.toJSON()).toStrictEqual({
122+
...base,
123+
shared_client_theme: {
124+
colors: ['5865F2', '7258F2'],
125+
gradient_angle: 0,
126+
base_mix: 58,
127+
base_theme: 1,
128+
},
129+
});
130+
});
131+
132+
test('GIVEN a message with a function to update shared client theme THEN return valid toJSON data', () => {
133+
const message = new MessageBuilder().updateSharedClientTheme((theme) =>
134+
theme.setColors(['5865F2']).setGradientAngle(90).setBaseMix(100),
135+
);
136+
137+
expect(message.toJSON()).toStrictEqual({
138+
...base,
139+
shared_client_theme: {
140+
colors: ['5865F2'],
141+
gradient_angle: 90,
142+
base_mix: 100,
143+
base_theme: undefined,
144+
},
145+
});
146+
});
147+
148+
test('GIVEN a message with a shared client theme then cleared THEN shared_client_theme is undefined', () => {
149+
const message = new MessageBuilder()
150+
.setSharedClientTheme(new SharedClientThemeBuilder().setColors(['5865F2']).setGradientAngle(0).setBaseMix(50))
151+
.clearSharedClientTheme();
152+
153+
expect(message.toJSON()).toStrictEqual(base);
154+
});
155+
156+
test('GIVEN a SharedClientThemeBuilder with too many colors THEN it throws', () => {
157+
const theme = new SharedClientThemeBuilder()
158+
.setColors(['111111', '222222', '333333', '444444', '555555', '666666'])
159+
.setGradientAngle(0)
160+
.setBaseMix(50);
161+
162+
expect(() => theme.toJSON()).toThrow();
163+
});
164+
165+
test('GIVEN a SharedClientThemeBuilder with out of range gradient angle THEN it throws', () => {
166+
const theme = new SharedClientThemeBuilder().setColors(['5865F2']).setGradientAngle(400).setBaseMix(50);
167+
expect(() => theme.toJSON()).toThrow();
168+
});
169+
170+
test('GIVEN a SharedClientThemeBuilder with out of range base mix THEN it throws', () => {
171+
const theme = new SharedClientThemeBuilder().setColors(['5865F2']).setGradientAngle(0).setBaseMix(150);
172+
expect(() => theme.toJSON()).toThrow();
173+
});
174+
175+
test('GIVEN a shared client theme with base_theme set THEN clearBaseTheme works correctly', () => {
176+
const theme = new SharedClientThemeBuilder()
177+
.setColors(['5865F2'])
178+
.setGradientAngle(0)
179+
.setBaseMix(50)
180+
.setBaseTheme(BaseThemeType.Light)
181+
.clearBaseTheme();
182+
183+
expect(theme.toJSON(false)).toStrictEqual({
184+
colors: ['5865F2'],
185+
gradient_angle: 0,
186+
base_mix: 50,
187+
base_theme: undefined,
188+
});
106189
});
107190
});
108191
});

packages/builders/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export * from './messages/Assertions.js';
8888
export * from './messages/Attachment.js';
8989
export * from './messages/Message.js';
9090
export * from './messages/MessageReference.js';
91+
export * from './messages/SharedClientTheme.js';
9192

9293
export * from './util/normalizeArray.js';
9394
export * from './util/resolveBuilder.js';

packages/builders/src/messages/Assertions.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Buffer } from 'node:buffer';
2-
import { AllowedMentionsTypes, ComponentType, MessageFlags, MessageReferenceType } from 'discord-api-types/v10';
2+
import { AllowedMentionsTypes, BaseThemeType, ComponentType, MessageFlags, MessageReferenceType } from 'discord-api-types/v10';
33
import { z } from 'zod';
44
import { snowflakePredicate } from '../Assertions.js';
55
import { embedPredicate } from './embed/Assertions.js';
@@ -79,12 +79,20 @@ const basicActionRowPredicate = z.object({
7979
.array(),
8080
});
8181

82+
export const sharedClientThemePredicate = z.object({
83+
colors: z.string().array().max(5),
84+
gradient_angle: z.number().int().min(0).max(360),
85+
base_mix: z.number().int().min(0).max(100),
86+
base_theme: z.enum(BaseThemeType).optional(),
87+
});
88+
8289
const messageNoComponentsV2Predicate = baseMessagePredicate
8390
.extend({
8491
content: z.string().max(2_000).optional(),
8592
embeds: embedPredicate.array().max(10).optional(),
8693
sticker_ids: z.array(z.string()).max(3).optional(),
8794
poll: pollPredicate.optional(),
95+
shared_client_theme: sharedClientThemePredicate.optional(),
8896
components: basicActionRowPredicate.array().max(5).optional(),
8997
flags: z
9098
.int()
@@ -100,8 +108,9 @@ const messageNoComponentsV2Predicate = baseMessagePredicate
100108
data.poll !== undefined ||
101109
(data.attachments !== undefined && data.attachments.length > 0) ||
102110
(data.components !== undefined && data.components.length > 0) ||
103-
(data.sticker_ids !== undefined && data.sticker_ids.length > 0),
104-
{ error: 'Messages must have content, embeds, a poll, attachments, components or stickers' },
111+
(data.sticker_ids !== undefined && data.sticker_ids.length > 0) ||
112+
data.shared_client_theme !== undefined,
113+
{ error: 'Messages must have content, embeds, a poll, attachments, components, stickers, or a shared client theme' },
105114
);
106115

107116
const allTopLevelComponentsPredicate = z
@@ -134,6 +143,7 @@ const messageComponentsV2Predicate = baseMessagePredicate.extend({
134143
embeds: z.array(z.never()).nullish(),
135144
sticker_ids: z.array(z.never()).nullish(),
136145
poll: z.null().optional(),
146+
shared_client_theme: z.null().optional(),
137147
});
138148

139149
export const messagePredicate = z.union([messageNoComponentsV2Predicate, messageComponentsV2Predicate]);

packages/builders/src/messages/Message.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
APISeparatorComponent,
1818
APITextDisplayComponent,
1919
APIMessageTopLevelComponent,
20+
APIMessageSharedClientTheme
2021
} from 'discord-api-types/v10';
2122
import { ActionRowBuilder } from '../components/ActionRow.js';
2223
import { ComponentBuilder } from '../components/Component.js';
@@ -35,13 +36,14 @@ import { AllowedMentionsBuilder } from './AllowedMentions.js';
3536
import { fileBodyMessagePredicate, messagePredicate } from './Assertions.js';
3637
import { AttachmentBuilder } from './Attachment.js';
3738
import { MessageReferenceBuilder } from './MessageReference.js';
39+
import { SharedClientThemeBuilder } from './SharedClientTheme.js';
3840
import { EmbedBuilder } from './embed/Embed.js';
3941
import { PollBuilder } from './poll/Poll.js';
4042

4143
export interface MessageBuilderData extends Partial<
4244
Omit<
4345
RESTPostAPIChannelMessageJSONBody,
44-
'allowed_mentions' | 'attachments' | 'components' | 'embeds' | 'message_reference' | 'poll'
46+
'allowed_mentions' | 'attachments' | 'components' | 'embeds' | 'message_reference' | 'poll' | 'shared_client_theme'
4547
>
4648
> {
4749
allowed_mentions?: AllowedMentionsBuilder;
@@ -50,6 +52,7 @@ export interface MessageBuilderData extends Partial<
5052
embeds: EmbedBuilder[];
5153
message_reference?: MessageReferenceBuilder;
5254
poll?: PollBuilder;
55+
shared_client_theme?: SharedClientThemeBuilder;
5356
}
5457

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

9598
this.data = {
9699
...structuredClone(rest),
@@ -100,6 +103,7 @@ export class MessageBuilder
100103
poll: poll && new PollBuilder(poll),
101104
components: components.map((component) => createComponentBuilder(component)),
102105
message_reference: message_reference && new MessageReferenceBuilder(message_reference),
106+
shared_client_theme: shared_client_theme && new SharedClientThemeBuilder(shared_client_theme),
103107
};
104108
}
105109

@@ -636,6 +640,39 @@ export class MessageBuilder
636640
return this;
637641
}
638642

643+
/**
644+
* Sets the shared client theme for this message.
645+
*
646+
* @param theme - The shared client theme to set
647+
*/
648+
public setSharedClientTheme(
649+
theme:
650+
| APIMessageSharedClientTheme
651+
| SharedClientThemeBuilder
652+
| ((builder: SharedClientThemeBuilder) => SharedClientThemeBuilder),
653+
): this {
654+
this.data.shared_client_theme = resolveBuilder(theme, SharedClientThemeBuilder);
655+
return this;
656+
}
657+
658+
/**
659+
* Updates the shared client theme for this message (and creates it if it doesn't exist).
660+
*
661+
* @param updater - The function to update the shared client theme with
662+
*/
663+
public updateSharedClientTheme(updater: (builder: SharedClientThemeBuilder) => void): this {
664+
updater((this.data.shared_client_theme ??= new SharedClientThemeBuilder()));
665+
return this;
666+
}
667+
668+
/**
669+
* Clears the shared client theme for this message.
670+
*/
671+
public clearSharedClientTheme(): this {
672+
this.data.shared_client_theme = undefined;
673+
return this;
674+
}
675+
639676
/**
640677
* Serializes this builder to API-compatible JSON data.
641678
*
@@ -644,7 +681,7 @@ export class MessageBuilder
644681
* @param validationOverride - Force validation to run/not run regardless of your global preference
645682
*/
646683
public toJSON(validationOverride?: boolean): RESTPostAPIChannelMessageJSONBody {
647-
const { poll, allowed_mentions, attachments, embeds, components, message_reference, ...rest } = this.data;
684+
const { poll, allowed_mentions, attachments, embeds, components, message_reference, shared_client_theme, ...rest } = this.data;
648685

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

661699
validate(messagePredicate, data, validationOverride);
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { JSONEncodable } from '@discordjs/util';
2+
import type { APIMessageSharedClientTheme, BaseThemeType } from 'discord-api-types/v10';
3+
import { validate } from '../util/validation.js';
4+
import { sharedClientThemePredicate } from './Assertions.js';
5+
6+
/**
7+
* A builder that creates API-compatible JSON data for shared client themes.
8+
*
9+
* @see {@link https://discord.com/developers/docs/resources/message#shared-client-theme-object}
10+
*/
11+
export class SharedClientThemeBuilder implements JSONEncodable<APIMessageSharedClientTheme> {
12+
/**
13+
* The API data associated with this shared client theme.
14+
*/
15+
private readonly data: Partial<APIMessageSharedClientTheme>;
16+
17+
/**
18+
* Creates a new shared client theme builder.
19+
*
20+
* @param data - The API data to create this shared client theme with
21+
*/
22+
public constructor(data: Partial<APIMessageSharedClientTheme> = {}) {
23+
this.data = structuredClone(data);
24+
}
25+
26+
/**
27+
* Sets the colors of this theme.
28+
*
29+
* @remarks
30+
* A maximum of 5 hexadecimal-encoded colors may be provided.
31+
* @param colors - The hexadecimal-encoded colors to set (e.g. `'5865F2'`)
32+
*/
33+
public setColors(colors: string[]): this {
34+
this.data.colors = [...colors];
35+
return this;
36+
}
37+
38+
/**
39+
* Sets the gradient angle of this theme.
40+
*
41+
* @remarks
42+
* The value must be between `0` and `360` (inclusive).
43+
* @param angle - The gradient angle (direction of theme colors)
44+
*/
45+
public setGradientAngle(angle: number): this {
46+
this.data.gradient_angle = angle;
47+
return this;
48+
}
49+
50+
/**
51+
* Sets the base mix (intensity) of this theme.
52+
*
53+
* @remarks
54+
* The value must be between `0` and `100` (inclusive).
55+
* @param baseMix - The base mix intensity
56+
*/
57+
public setBaseMix(baseMix: number): this {
58+
this.data.base_mix = baseMix;
59+
return this;
60+
}
61+
62+
/**
63+
* Sets the base theme (mode) of this theme.
64+
*
65+
* @param baseTheme - The base theme mode, or `null` to clear
66+
*/
67+
public setBaseTheme(baseTheme: BaseThemeType | null): this {
68+
this.data.base_theme = baseTheme;
69+
return this;
70+
}
71+
72+
/**
73+
* Clears the base theme of this theme.
74+
*/
75+
public clearBaseTheme(): this {
76+
this.data.base_theme = undefined;
77+
return this;
78+
}
79+
80+
/**
81+
* Serializes this builder to API-compatible JSON data.
82+
*
83+
* Note that by disabling validation, there is no guarantee that the resulting object will be valid.
84+
*
85+
* @param validationOverride - Force validation to run/not run regardless of your global preference
86+
*/
87+
public toJSON(validationOverride?: boolean): APIMessageSharedClientTheme {
88+
const data = structuredClone(this.data);
89+
validate(sharedClientThemePredicate, data, validationOverride);
90+
return data as APIMessageSharedClientTheme;
91+
}
92+
}

packages/discord.js/src/structures/Message.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,31 @@ class Message extends Base {
499499
} else {
500500
this.call ??= null;
501501
}
502+
503+
/**
504+
* The shared client theme sent with this message
505+
*
506+
* @typedef {Object} MessageSharedClientTheme
507+
* @property {string[]} colors The hexadecimal-encoded colors of the theme (max of 5)
508+
* @property {number} gradientAngle The direction of the theme's colors (0–360)
509+
* @property {number} baseMix The intensity of the theme's colors (0–100)
510+
* @property {?BaseThemeType} baseTheme The mode of the theme
511+
*/
512+
if (data.shared_client_theme) {
513+
/**
514+
* The shared client theme sent with this message
515+
*
516+
* @type {?MessageSharedClientTheme}
517+
*/
518+
this.sharedClientTheme = {
519+
colors: data.shared_client_theme.colors,
520+
gradientAngle: data.shared_client_theme.gradient_angle,
521+
baseMix: data.shared_client_theme.base_mix,
522+
baseTheme: data.shared_client_theme.base_theme ?? null,
523+
};
524+
} else {
525+
this.sharedClientTheme ??= null;
526+
}
502527
}
503528

504529
/**

0 commit comments

Comments
 (0)