Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Premium buttons #10353

Merged
merged 10 commits into from
Jul 4, 2024
46 changes: 46 additions & 0 deletions packages/builders/__tests__/components/button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ describe('Button Components', () => {
button.toJSON();
}).not.toThrowError();

expect(() => {
const button = buttonComponent().setSKUId('123456789012345678').setStyle(ButtonStyle.Premium);
button.toJSON();
}).not.toThrowError();

expect(() => buttonComponent().setURL('https://google.com')).not.toThrowError();
});

Expand Down Expand Up @@ -101,6 +106,47 @@ describe('Button Components', () => {
button.toJSON();
}).toThrowError();

expect(() => {
const button = buttonComponent().setStyle(ButtonStyle.Primary).setSKUId('123456789012345678');
button.toJSON();
}).toThrowError();

expect(() => {
const button = buttonComponent()
.setStyle(ButtonStyle.Secondary)
.setLabel('button')
.setSKUId('123456789012345678');

button.toJSON();
}).toThrowError();

expect(() => {
const button = buttonComponent()
.setStyle(ButtonStyle.Success)
.setEmoji({ name: '😇' })
.setSKUId('123456789012345678');

button.toJSON();
}).toThrowError();

expect(() => {
const button = buttonComponent()
.setStyle(ButtonStyle.Danger)
.setCustomId('test')
.setSKUId('123456789012345678');

button.toJSON();
}).toThrowError();

expect(() => {
const button = buttonComponent()
.setStyle(ButtonStyle.Link)
.setURL('https://google.com')
.setSKUId('123456789012345678');

button.toJSON();
}).toThrowError();

// @ts-expect-error: Invalid style
expect(() => buttonComponent().setStyle(24)).toThrowError();
expect(() => buttonComponent().setLabel(longStr)).toThrowError();
Expand Down
37 changes: 26 additions & 11 deletions packages/builders/src/components/Assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,21 +81,36 @@ export function validateRequiredButtonParameters(
label?: string,
emoji?: APIMessageComponentEmoji,
customId?: string,
skuId?: string,
url?: string,
) {
if (url && customId) {
throw new RangeError('URL and custom id are mutually exclusive');
}
if (style === ButtonStyle.Premium) {
if (!skuId) {
throw new RangeError('Premium buttons must have an SKU id.');
}

if (!label && !emoji) {
throw new RangeError('Buttons must have a label and/or an emoji');
}
if (customId || label || url || emoji) {
throw new RangeError('Premium buttons cannot have a custom id, label, URL, or emoji.');
}
} else {
if (skuId) {
throw new RangeError('Non-premium buttons must not have an SKU id.');
}

if (url && customId) {
throw new RangeError('URL and custom id are mutually exclusive.');
}

if (!label && !emoji) {
throw new RangeError('Non-premium buttons must have a label and/or an emoji.');
}

if (style === ButtonStyle.Link) {
if (!url) {
throw new RangeError('Link buttons must have a url');
if (style === ButtonStyle.Link) {
if (!url) {
throw new RangeError('Link buttons must have a URL.');
}
} else if (url) {
throw new RangeError('Non-premium and non-link buttons cannot have a URL.');
}
} else if (url) {
throw new RangeError('Non-link buttons cannot have a url');
}
}
13 changes: 13 additions & 0 deletions packages/builders/src/components/button/Button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type APIButtonComponentWithURL,
type APIMessageComponentEmoji,
type ButtonStyle,
type Snowflake,
} from 'discord-api-types/v10';
import {
buttonLabelValidator,
Expand Down Expand Up @@ -89,6 +90,17 @@ export class ButtonBuilder extends ComponentBuilder<APIButtonComponent> {
return this;
}

/**
* Sets the SKU id that represents a purchasable SKU for this button.
*
* @remarks Only available when using premium-style buttons.
* @param skuId - The SKU id to use
*/
public setSKUId(skuId: Snowflake) {
(this.data as APIButtonComponentWithSKUId).sku_id = skuId;
return this;
}

/**
* Sets the emoji to display on this button.
*
Expand Down Expand Up @@ -128,6 +140,7 @@ export class ButtonBuilder extends ComponentBuilder<APIButtonComponent> {
(this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).label,
(this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).emoji,
(this.data as APIButtonComponentWithCustomId).custom_id,
(this.data as APIButtonComponentWithSKUId).sku_id,
(this.data as APIButtonComponentWithURL).url,
);

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/api/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ export class InteractionsAPI {
* @param interactionId - The id of the interaction
* @param interactionToken - The token of the interaction
* @param options - The options for sending the premium required response
* @deprecated Sending a premium-style button is the new Discord behaviour.
*/
public async sendPremiumRequired(
interactionId: Snowflake,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';

const { deprecate } = require('node:util');
const { isJSONEncodable } = require('@discordjs/util');
const { InteractionResponseType, MessageFlags, Routes, InteractionType } = require('discord-api-types/v10');
const { DiscordjsError, ErrorCodes } = require('../../errors');
Expand Down Expand Up @@ -266,6 +267,7 @@ class InteractionResponses {
/**
* Responds to the interaction with an upgrade button.
* <info>Only available for applications with monetization enabled.</info>
* @deprecated Sending a premium-style button is the new Discord behaviour.
Jiralite marked this conversation as resolved.
Show resolved Hide resolved
* @returns {Promise<void>}
*/
async sendPremiumRequired() {
Expand Down Expand Up @@ -337,4 +339,10 @@ class InteractionResponses {
}
}

InteractionResponses.prototype.sendPremiumRequired = deprecate(
InteractionResponses.prototype.sendPremiumRequired,
// eslint-disable-next-line max-len
'InteractionResponses#sendPremiumRequired() is deprecated. Sending a premium-style button is the new Discord behaviour.',
);

module.exports = InteractionResponses;
3 changes: 3 additions & 0 deletions packages/discord.js/typings/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,7 @@ export abstract class CommandInteraction<Cached extends CacheType = CacheType> e
| ModalComponentData
| APIModalInteractionResponseCallbackData,
): Promise<void>;
/** @deprecated Sending a premium-style button is the new Discord behaviour. */
public sendPremiumRequired(): Promise<void>;
public awaitModalSubmit(
options: AwaitModalSubmitOptions<ModalSubmitInteraction>,
Expand Down Expand Up @@ -2261,6 +2262,7 @@ export class MessageComponentInteraction<Cached extends CacheType = CacheType> e
| ModalComponentData
| APIModalInteractionResponseCallbackData,
): Promise<void>;
/** @deprecated Sending a premium-style button is the new Discord behaviour. */
public sendPremiumRequired(): Promise<void>;
public awaitModalSubmit(
options: AwaitModalSubmitOptions<ModalSubmitInteraction>,
Expand Down Expand Up @@ -2460,6 +2462,7 @@ export class ModalSubmitInteraction<Cached extends CacheType = CacheType> extend
options: InteractionDeferUpdateOptions & { fetchReply: true },
): Promise<Message<BooleanCache<Cached>>>;
public deferUpdate(options?: InteractionDeferUpdateOptions): Promise<InteractionResponse<BooleanCache<Cached>>>;
/** @deprecated Sending a premium-style button is the new Discord behaviour. */
public sendPremiumRequired(): Promise<void>;
public inGuild(): this is ModalSubmitInteraction<'raw' | 'cached'>;
public inCachedGuild(): this is ModalSubmitInteraction<'cached'>;
Expand Down
8 changes: 7 additions & 1 deletion packages/discord.js/typings/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ import {
MentionableSelectMenuComponent,
Poll,
} from '.';
import { expectAssignable, expectNotAssignable, expectNotType, expectType } from 'tsd';
import { expectAssignable, expectDeprecated, expectNotAssignable, expectNotType, expectType } from 'tsd';
import type { ContextMenuCommandBuilder, SlashCommandBuilder } from '@discordjs/builders';
import { ReadonlyCollection } from '@discordjs/collection';

Expand Down Expand Up @@ -1763,6 +1763,7 @@ client.on('interactionCreate', async interaction => {
expectType<AnySelectMenuInteraction | ButtonInteraction>(interaction);
expectType<MessageActionRowComponent | APIButtonComponent | APISelectMenuComponent>(interaction.component);
expectType<Message>(interaction.message);
expectDeprecated(interaction.sendPremiumRequired());
if (interaction.inCachedGuild()) {
expectAssignable<MessageComponentInteraction>(interaction);
expectType<MessageActionRowComponent>(interaction.component);
Expand Down Expand Up @@ -1950,6 +1951,7 @@ client.on('interactionCreate', async interaction => {
interaction.type === InteractionType.ApplicationCommand &&
interaction.commandType === ApplicationCommandType.ChatInput
) {
expectDeprecated(interaction.sendPremiumRequired());
if (interaction.inRawGuild()) {
expectNotAssignable<Interaction<'cached'>>(interaction);
expectAssignable<ChatInputCommandInteraction>(interaction);
Expand Down Expand Up @@ -2073,6 +2075,10 @@ client.on('interactionCreate', async interaction => {
expectType<Promise<Message>>(interaction.followUp({ content: 'a' }));
}
}

if (interaction.isModalSubmit()) {
expectDeprecated(interaction.sendPremiumRequired());
}
});

declare const shard: Shard;
Expand Down
15 changes: 15 additions & 0 deletions packages/formatters/__tests__/formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { URL } from 'node:url';
import { describe, test, expect, vitest } from 'vitest';
import {
applicationDirectory,
chatInputApplicationCommandMention,
blockQuote,
bold,
Expand Down Expand Up @@ -313,6 +314,20 @@ describe('Message formatters', () => {
});
});

describe('applicationDirectory', () => {
test('GIVEN application id THEN returns application directory store', () => {
expect(applicationDirectory('123456789012345678')).toEqual(
'https://discord.com/application-directory/123456789012345678/store',
);
});

test('GIVEN application id AND SKU id THEN returns SKU within the application directory store', () => {
expect(applicationDirectory('123456789012345678', '123456789012345678')).toEqual(
'https://discord.com/application-directory/123456789012345678/store/123456789012345678',
);
});
});

describe('Faces', () => {
test('GIVEN Faces.Shrug THEN returns "¯\\_(ツ)_/¯"', () => {
expect<'¯\\_(ツ)_/¯'>(Faces.Shrug).toEqual('¯\\_(ツ)_/¯');
Expand Down
33 changes: 33 additions & 0 deletions packages/formatters/src/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,39 @@ export function time(timeOrSeconds?: Date | number, style?: TimestampStylesStrin
return typeof style === 'string' ? `<t:${timeOrSeconds}:${style}>` : `<t:${timeOrSeconds}>`;
}

/**
* Formats an application directory link.
*
* @typeParam ApplicationId - This is inferred by the supplied application id
* @param applicationId - The application id
*/
export function applicationDirectory<ApplicationId extends Snowflake>(
applicationId: ApplicationId,
): `https://discord.com/application-directory/${ApplicationId}/store`;

/**
* Formats an application directory SKU link.
*
* @typeParam ApplicationId - This is inferred by the supplied application id
* @typeParam SKUId - This is inferred by the supplied SKU id
* @param applicationId - The application id
* @param skuId - The SKU id
*/
export function applicationDirectory<ApplicationId extends Snowflake, SKUId extends Snowflake>(
applicationId: ApplicationId,
skuId: SKUId,
): `https://discord.com/application-directory/${ApplicationId}/store/${SKUId}`;

export function applicationDirectory<ApplicationId extends Snowflake, SKUId extends Snowflake>(
applicationId: ApplicationId,
skuId?: SKUId,
):
| `https://discord.com/application-directory/${ApplicationId}/store/${SKUId}`
| `https://discord.com/application-directory/${ApplicationId}/store` {
const url = `https://discord.com/application-directory/${applicationId}/store` as const;
return skuId ? `${url}/${skuId}` : url;
}

/**
* The {@link https://discord.com/developers/docs/reference#message-formatting-timestamp-styles | message formatting timestamp styles}
* supported by Discord.
Expand Down
Loading