From 010caba73816183c0881f85a28c705c8fe1fe176 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Sat, 9 Sep 2023 21:20:25 +0200 Subject: [PATCH] feat: Merged pagination, embeds and module generation into ui.ts (#35) * feat: Merged pagination, embeds and module generation into ui.ts * fix: Made main get embed from the file instead of util --------- Co-authored-by: zleyyij --- src/core/embed.ts | 149 --------------------- src/core/main.ts | 2 +- src/core/slash_commands.ts | 63 --------- src/core/{pagination.ts => ui.ts} | 215 ++++++++++++++++++++++++++++-- src/core/util.ts | 3 +- 5 files changed, 208 insertions(+), 224 deletions(-) delete mode 100644 src/core/embed.ts rename src/core/{pagination.ts => ui.ts} (61%) diff --git a/src/core/embed.ts b/src/core/embed.ts deleted file mode 100644 index 3b5c38f..0000000 --- a/src/core/embed.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * @file - * - * This file contains helper utilities for embed generation and general formatting. - * - * Possible examples may include error embeds, success embeds, confirm/deny embeds - */ - -import { - APIEmbed, - ChatInputCommandInteraction, - ButtonBuilder, - ButtonStyle, - ActionRowBuilder, - InteractionResponse, - Message, -} from 'discord.js'; -import {replyToInteraction} from './slash_commands.js'; - -/** - * Used in pairing with `{@link confirmEmbed()}`, this is a way to indicate whether or not the user confirmed a choice, and is passed as - * the contents of the Promise returned by `{@link confirmEmbed()}`. - */ -export enum ConfirmEmbedResponse { - Confirmed = 'confirmed', - Denied = 'denied', -} - -/** - * Helper utilities used to speed up embed work - */ -export const embed = { - /** - * simple function that generates a minimal APIEmbed with only the `description` set. - * Other options can be set with `otherOptions` - * - * @param displayText The text you'd like the embed to contain - * - * @param otherOptions Any custom changes you'd like to make to the embed. - * @see {@link https://discordjs.guide/popular-topics/embeds.html#using-an-embed-object} for specific options - * - * @example - * // Just the description set - * simpleEmbed("they don't did make em like they anymore these days do"); - * // Maybe you want to make the embed red - * simpleEmbed("they don't did make em like they anymore these days do", { color: 0xFF0000 }); - */ - simpleEmbed(displayText: string, otherOptions: APIEmbed = {}): APIEmbed { - otherOptions.description = displayText; - return otherOptions; - }, - - /** - * A preformatted embed that should be used to indicate command failure - */ - errorEmbed(errorText: string): APIEmbed { - const responseEmbed: APIEmbed = { - description: '❌ ' + errorText, - color: 0xf92f60, - footer: { - text: 'Operation failed.', - }, - }; - return responseEmbed; - }, - - successEmbed(successText: string): APIEmbed { - const responseEmbed: APIEmbed = { - color: 0x379c6f, - description: '✅ ' + successText, - }; - return responseEmbed; - }, - - infoEmbed(infoText: string): APIEmbed { - const responseEmbed: APIEmbed = { - color: 0x2e8eea, - description: infoText, - }; - return responseEmbed; - }, - - /** - * This provides a graceful way to ask a user whether or not they want something to happen. - * If the interaction is ephemeral, the embed has to be deleted or edited manually, since - * ephemeral messages can't be deleted using .delete() - * @param prompt will be displayed in the embed with the `description` field - */ - async confirmEmbed( - prompt: string, - // this might break if reply() is called twice - interaction: ChatInputCommandInteraction, - timeout = 60 - ): Promise { - // https://discordjs.guide/message-components/action-rows.html - const confirm = new ButtonBuilder() - .setCustomId(ConfirmEmbedResponse.Confirmed) - .setLabel('Confirm') - .setStyle(ButtonStyle.Success); - const deny = new ButtonBuilder() - .setCustomId(ConfirmEmbedResponse.Denied) - .setLabel('Cancel') - .setStyle(ButtonStyle.Secondary); - - const actionRow = new ActionRowBuilder().addComponents( - confirm, - deny - ); - - // send the confirmation - const response: InteractionResponse | Message = - await replyToInteraction(interaction, { - embeds: [this.infoEmbed(prompt)], - components: [actionRow], - }); - - // listen for a button interaction - try { - const buttonInteraction = await response.awaitMessageComponent({ - filter: i => i.user.id === interaction.member?.user.id, - time: timeout * 1000, - }); - // Ephemeral messages can't be deleted using message.delete() - if (!interaction.ephemeral) { - response.delete(); - } - return buttonInteraction.customId as ConfirmEmbedResponse; - } catch { - // awaitMessageComponent throws an error when the timeout was reached, so this behavior assumes - // that no other errors were thrown - response.edit({ - embeds: [ - this.errorEmbed( - 'No interaction was made by the timeout limit, cancelling.' - ), - ], - components: [], - }); - // delete the embed after 15 seconds - setTimeout(() => { - // Ephemeral messages can't be deleted using message.delete() - if (!interaction.ephemeral) { - response.delete(); - } - }, 15_000); - return ConfirmEmbedResponse.Denied; - } - }, -}; diff --git a/src/core/main.ts b/src/core/main.ts index 191fc0b..3e3e3f1 100644 --- a/src/core/main.ts +++ b/src/core/main.ts @@ -12,7 +12,7 @@ import {DependencyStatus, RootModule, SubModule, modules} from './modules.js'; import {client} from './api.js'; import path from 'path'; import {fileURLToPath} from 'url'; -import {embed} from './embed.js'; +import {embed} from './ui.js'; import { generateSlashCommandForModule, registerSlashCommandSet, diff --git a/src/core/slash_commands.ts b/src/core/slash_commands.ts index dec4fa7..dbceeea 100644 --- a/src/core/slash_commands.ts +++ b/src/core/slash_commands.ts @@ -28,12 +28,6 @@ import { BaseMessageOptions, Message, InteractionResponse, - TextInputBuilder, - ModalBuilder, - TextInputStyle, - ActionRowBuilder, - RestOrArray, - ModalActionRowComponentBuilder, } from 'discord.js'; import {client} from './api.js'; import {botConfig} from './config.js'; @@ -337,60 +331,3 @@ export async function replyToInteraction( return await interaction.reply(payload); } - -/** - * A single input field of a modal - * @param id The ID to refer to the input field as - * @param label The label of the input field - * @param style The style to use (Short or Paragraph) - * @param maxLength The maximum input length - */ -interface inputFieldOptions { - id: string; - label: string; - style: TextInputStyle; - maxLength: number; -} - -/** - * The modal generation options - * @param id The ID to refer to the modal as - * @param title The title of the modal - * @param fields An array of inputFieldOptions - */ -interface modalOptions { - id: string; - title: string; - fields: inputFieldOptions[]; -} - -/** - * Generates a modal from args - * Takes a {@link inputFieldOptions} object as an argument - * @returns The finished modal object - */ -export function generateModal({id, title, fields}: modalOptions): ModalBuilder { - const modal: ModalBuilder = new ModalBuilder() - .setCustomId(id) - .setTitle(title); - - const components: RestOrArray> = []; - - // Adds all components to the modal - for (const field of fields) { - const modalComponent: TextInputBuilder = new TextInputBuilder() - .setCustomId(field.id) - .setLabel(field.label) - .setStyle(field.style) - .setMaxLength(field.maxLength); - - const actionRow = - new ActionRowBuilder().addComponents( - modalComponent - ); - components.push(actionRow); - } - modal.addComponents(components); - - return modal; -} diff --git a/src/core/pagination.ts b/src/core/ui.ts similarity index 61% rename from src/core/pagination.ts rename to src/core/ui.ts index 1e84c9f..4bcb9e6 100644 --- a/src/core/pagination.ts +++ b/src/core/ui.ts @@ -1,20 +1,160 @@ /** - *@file This file contains the code to send a paginated array of payloads. + * @file + * This file contains code for all ui related elements such as modals, response embeds and pagination. */ import { + APIEmbed, ChatInputCommandInteraction, - ActionRowBuilder, - ComponentType, - ButtonInteraction, - ButtonComponent, ButtonBuilder, ButtonStyle, + ActionRowBuilder, + ButtonInteraction, BaseMessageOptions, + TextInputStyle, + InteractionResponse, + ButtonComponent, + ModalActionRowComponentBuilder, + RestOrArray, + TextInputBuilder, + ComponentType, + ModalBuilder, + Message, EmbedField, EmbedBuilder, } from 'discord.js'; -import * as util from '../core/util.js'; +import {replyToInteraction} from './slash_commands.js'; + +/** + * Used in pairing with `{@link confirmEmbed()}`, this is a way to indicate whether or not the user confirmed a choice, and is passed as + * the contents of the Promise returned by `{@link confirmEmbed()}`. + */ +export enum ConfirmEmbedResponse { + Confirmed = 'confirmed', + Denied = 'denied', +} + +/** + * Helper utilities used to speed up embed work + */ +export const embed = { + /** + * simple function that generates a minimal APIEmbed with only the `description` set. + * Other options can be set with `otherOptions` + * + * @param displayText The text you'd like the embed to contain + * + * @param otherOptions Any custom changes you'd like to make to the embed. + * @see {@link https://discordjs.guide/popular-topics/embeds.html#using-an-embed-object} for specific options + * + * @example + * // Just the description set + * simpleEmbed("they don't did make em like they anymore these days do"); + * // Maybe you want to make the embed red + * simpleEmbed("they don't did make em like they anymore these days do", { color: 0xFF0000 }); + */ + simpleEmbed(displayText: string, otherOptions: APIEmbed = {}): APIEmbed { + otherOptions.description = displayText; + return otherOptions; + }, + + /** + * A preformatted embed that should be used to indicate command failure + */ + errorEmbed(errorText: string): APIEmbed { + const responseEmbed: APIEmbed = { + description: '❌ ' + errorText, + color: 0xf92f60, + footer: { + text: 'Operation failed.', + }, + }; + return responseEmbed; + }, + + successEmbed(successText: string): APIEmbed { + const responseEmbed: APIEmbed = { + color: 0x379c6f, + description: '✅ ' + successText, + }; + return responseEmbed; + }, + + infoEmbed(infoText: string): APIEmbed { + const responseEmbed: APIEmbed = { + color: 0x2e8eea, + description: infoText, + }; + return responseEmbed; + }, + + /** + * This provides a graceful way to ask a user whether or not they want something to happen. + * If the interaction is ephemeral, the embed has to be deleted or edited manually, since + * ephemeral messages can't be deleted using .delete() + * @param prompt will be displayed in the embed with the `description` field + */ + async confirmEmbed( + prompt: string, + // this might break if reply() is called twice + interaction: ChatInputCommandInteraction, + timeout = 60 + ): Promise { + // https://discordjs.guide/message-components/action-rows.html + const confirm = new ButtonBuilder() + .setCustomId(ConfirmEmbedResponse.Confirmed) + .setLabel('Confirm') + .setStyle(ButtonStyle.Success); + const deny = new ButtonBuilder() + .setCustomId(ConfirmEmbedResponse.Denied) + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary); + + const actionRow = new ActionRowBuilder().addComponents( + confirm, + deny + ); + + // send the confirmation + const response: InteractionResponse | Message = + await replyToInteraction(interaction, { + embeds: [this.infoEmbed(prompt)], + components: [actionRow], + }); + + // listen for a button interaction + try { + const buttonInteraction = await response.awaitMessageComponent({ + filter: i => i.user.id === interaction.member?.user.id, + time: timeout * 1000, + }); + // Ephemeral messages can't be deleted using message.delete() + if (!interaction.ephemeral) { + response.delete(); + } + return buttonInteraction.customId as ConfirmEmbedResponse; + } catch { + // awaitMessageComponent throws an error when the timeout was reached, so this behavior assumes + // that no other errors were thrown + response.edit({ + embeds: [ + this.errorEmbed( + 'No interaction was made by the timeout limit, cancelling.' + ), + ], + components: [], + }); + // delete the embed after 15 seconds + setTimeout(() => { + // Ephemeral messages can't be deleted using message.delete() + if (!interaction.ephemeral) { + response.delete(); + } + }, 15_000); + return ConfirmEmbedResponse.Denied; + } + }, +}; /** * A paginated message, implemented with a row of buttons that flip between various "pages" @@ -143,7 +283,7 @@ export class PaginatedMessage { if (this.payloads === null || this.payloads.length === 0) { return { embeds: [ - util.embed.errorEmbed( + embed.errorEmbed( 'Pagination error: No payloads were provided to paginate' ), ], @@ -164,7 +304,7 @@ export class PaginatedMessage { // Returns an error in place of the actual payload return { embeds: [ - util.embed.errorEmbed( + embed.errorEmbed( 'Pagination error: The pagination row is null, payload preparation failed' ), ], @@ -196,7 +336,7 @@ export class PaginatedMessage { timeout: number ) { let payload = this.renderCurrentPage(); - let botResponse = await util.replyToInteraction(interaction, payload); + let botResponse = await replyToInteraction(interaction, payload); // Sets up a listener to listen for control button pushes const continueButtonListener = botResponse.createMessageComponentCollector({ @@ -274,6 +414,63 @@ export class PaginatedMessage { } } +/** + * A single input field of a modal + */ +interface InputFieldOptions { + /** The ID to refer to the input field as */ + id: string; + /** The lavel of the input field */ + label: string; + /** The style to use (Short/Paragraph) */ + style: TextInputStyle; + /** The maximum length of the input */ + maxLength: number; +} + +/** + * The modal generation options + */ +interface ModalOptions { + /** The ID to refer to the modal as */ + id: string; + /** The title of the modal */ + title: string; + /** The fields of the modal */ + fields: InputFieldOptions[]; +} + +/** + * Generates a modal from args + * Takes a {@link inputFieldOptions} object as an argument + * @returns The finished modal object + */ +export function generateModal({id, title, fields}: ModalOptions): ModalBuilder { + const modal: ModalBuilder = new ModalBuilder() + .setCustomId(id) + .setTitle(title); + + const components: RestOrArray> = []; + + // Adds all components to the modal + for (const field of fields) { + const modalComponent: TextInputBuilder = new TextInputBuilder() + .setCustomId(field.id) + .setLabel(field.label) + .setStyle(field.style) + .setMaxLength(field.maxLength); + + const actionRow = + new ActionRowBuilder().addComponents( + modalComponent + ); + components.push(actionRow); + } + modal.addComponents(components); + + return modal; +} + /** * Function to create an array of payloads with embeds with fields separated properly * @param fields An array of embed fields diff --git a/src/core/util.ts b/src/core/util.ts index 6a07bb0..d8af268 100644 --- a/src/core/util.ts +++ b/src/core/util.ts @@ -5,11 +5,10 @@ * */ export * from './api.js'; -export * from './embed.js'; +export * from './ui.js'; export * from './slash_commands.js'; export * from './config.js'; export * from './logger.js'; export * from './modules.js'; export * from './main.js'; export * from './mongo.js'; -export * from './pagination.js';