import { isAnyInteraction, createPartitionedMessageRow, isMessageButtonInteractionData, isMessageInstance, type AnyInteractableInteraction, PaginatedMessage, type PaginatedMessageOptions, safelyReplyToInteraction, actionIsButtonOrMenu } from "@sapphire/discord.js-utilities"; import { isFunction, chunk, isObject } from "@sapphire/utilities"; import { type ComponentEmojiResolvable, type ButtonInteraction, EmbedBuilder, type User, ButtonBuilder, ButtonStyle, type StringSelectMenuInteraction, StringSelectMenuBuilder, ComponentType, type InteractionCollector, type EmbedData, type Message, type BaseMessageOptions, type InteractionReplyOptions, type WebhookMessageEditOptions, type CommandInteraction, type APIMessage, type Awaitable, type InteractionButtonComponentData, type LinkButtonComponentData, type StringSelectMenuComponentData } from "discord.js"; export class Paginator { prefix: string; suffix: string; lineSep: string; maxSize: number; currentPage: string; count: number; _pages: string[]; constructor(prefix = "```", suffix = "```", maxSize = 2000, lineSep = "\n") { this.prefix = prefix; this.suffix = suffix; this.maxSize = maxSize; this.lineSep = lineSep; this.clear(); } clear(clearPages = true) { if (this.prefix) { this.currentPage = this.prefix; this.count = this.prefix.length + this.lineSep.length; } else { this.currentPage = ""; this.count = 0; } if (clearPages) this._pages = []; } addLine(line = "", empty = false) { const maxPageSize = this.maxSize - this.prefix.length - this.suffix.length - 2 * this.lineSep.length; if (line.length > maxPageSize) throw new Error(`Line exceeds maximum page size ${maxPageSize}`); if (this.count + line.length + this.lineSep.length > this.maxSize - this.suffix.length) this.closePage(); this.count += line.length + this.lineSep.length; this.currentPage += this.lineSep + line; if (empty) { this.currentPage += this.lineSep + ""; this.count += this.lineSep.length; } } closePage() { if (this.suffix) this.currentPage += this.suffix; this._pages.push(this.currentPage); this.clear(false); } get length() { const total: number = this._pages.map((page) => page.length).reduce((a, b) => a + b); return total + this.count; } get pages() { return [...this._pages, this.currentPage]; } } export class WrappedPaginator extends Paginator { wrapOn: string[]; includeWrapped: boolean; constructor(prefix = "```", suffix = "```", maxSize = 2000, wrapOn = ["\n", " "], includeWrapped = true) { super(prefix, suffix, maxSize); this.wrapOn = wrapOn; this.includeWrapped = includeWrapped; } addLine(line = "", empty = false) { const trueMaxSize = this.maxSize - this.prefix.length - 2; while (line.length > trueMaxSize) { const searchString = line.slice(0, trueMaxSize - 1); let wrapped = false; for (const delimiter of this.wrapOn) { const position = searchString.lastIndexOf(delimiter); if (position > 0) { super.addLine(line.slice(0, Math.max(0, position)), empty); wrapped = true; line = this.includeWrapped ? line.slice(Math.max(0, position)) : line.slice(Math.max(0, position + delimiter.length)); break; } } if (!wrapped) break; } super.addLine(line, empty); } } // eslint-disable-next-line @typescript-eslint/naming-convention export class _PaginatorInterface extends PaginatedMessage { selectStringOptions: { index?: number; value: string; emoji?: ComponentEmojiResolvable; label: string; default?: boolean; description?: string; }[] = []; // eslint-disable-next-line unicorn/consistent-function-scoping private genaratedOptions: number[]; private displaySubPage: number; constructor({ pages, actions, template, pageIndexPrefix, embedFooterSeparator, paginatedMessageData = null }: PaginatedMessageOptions = {}) { super({ pages, actions, template, pageIndexPrefix, embedFooterSeparator, paginatedMessageData }); this.displaySubPage = 0; } #thisMazeWasNotMeantForYouContent = { content: "This maze wasn't meant for you...what did you do." }; /** * Adds a page to the existing ones. This will be added as the last page. * @remark While you can use this method you should first check out * {@link PaginatorInteface.addPageBuilder}, * {@link PaginatorInteface.addPageContent} and * {@link PaginatorInteface.addPageEmbed} as * these are easier functional methods of adding pages and will likely already suffice for your needs. * * @param page The page to add. */ public addPage(page): this { this.pages.push(page); return this; } private async setSelectOptions( messageOrInteraction: Message | AnyInteractableInteraction, targetUser: User ) { console.log(this.pages.length); this.genaratedOptions = Array.from({ length: this.pages.length }, (_, i) => i + 1); this.selectStringOptions = await Promise.all( chunk(this.genaratedOptions, 10)[this.displaySubPage].map(async (_, index) => { return { ...(await this.selectMenuOptions( _, this.resolvePaginatedMessageInternationalizationContext(messageOrInteraction, targetUser) )), value: _.toString(), index }; }) ); const SubPage = this.displaySubPage; console.log(this.genaratedOptions); //const SubPageNormal = this.displaySubPage + 1; const GenOptions = chunk(this.genaratedOptions, 10).length; //const page if ( this.selectStringOptions.at(0)!.index == 0 && this.genaratedOptions.length > 10 && this.displaySubPage > 0 ) this.selectStringOptions.splice(0, 0, { label: `Back to the previous page of ${SubPage - 1 === 0 ? "1" : SubPage - 1}/${GenOptions}`, value: `-${this.displaySubPage}/${chunk(this.genaratedOptions, 10).length}` }); console.log( "Continue Page?", this.selectStringOptions.at(-1)!.index === 9 && this.genaratedOptions.length > 10 ); console.log(this.displaySubPage, chunk(this.genaratedOptions, 10).length); if (this.selectStringOptions.at(-1)!.index === 9 && this.genaratedOptions.length > 10) this.selectStringOptions.push({ label: `Continue to next page of ${SubPage + 2}/${GenOptions}`, value: `+${this.displaySubPage}/${chunk(this.genaratedOptions, 10).length}` }); console.log(this.selectStringOptions); } /** * Sets up the message. * * @param messageOrInteraction The message or interaction that triggered this {@link PaginatorInteface}. * Generally this will be the command message or an interaction * (either a {@link CommandInteraction}, a {@link ContextMenuInteraction}, a {@link StringSelectMenuInteraction} or a {@link ButtonInteraction}), * but it can also be another message from your client, i.e. to indicate a loading state. * * @param targetUser The author the handler is for. */ protected async setUpMessage( messageOrInteraction: Message | AnyInteractableInteraction, targetUser: User ): Promise { // Get the current page let page = this.messages[this.index]!; // If the page is a callback function such as with `addAsyncPageEmbed` then resolve it here page = isFunction(page) ? await page(this.index, this.pages, this) : page; // Merge in the advanced options page = { ...page, ...(this.paginatedMessageData ??= {}) }; // If we do not have more than 1 page then there is no reason to add message components if (this.pages.length > 1) { const messageComponents = await Promise.all( [...this.actions.values()].map>( async (interaction) => { if (isMessageButtonInteractionData(interaction)) { return new ButtonBuilder(interaction); } else { await this.setSelectOptions(messageOrInteraction, targetUser); return new StringSelectMenuBuilder({ options: this.selectStringOptions, placeholder: this.selectMenuPlaceholder, ...interaction }); } } ) ); page.components = createPartitionedMessageRow(messageComponents); } if (this.response) { if (isAnyInteraction(this.response)) { await (this.response.replied || this.response.deferred ? this.response.editReply(page as WebhookMessageEditOptions) : this.response.reply(page as InteractionReplyOptions)); } else if (isMessageInstance(this.response)) { await this.response.edit(page as WebhookMessageEditOptions); } } else if (isAnyInteraction(messageOrInteraction)) { if (messageOrInteraction.replied || messageOrInteraction.deferred) { const editReplyResponse = await messageOrInteraction.editReply(page); this.response = messageOrInteraction.ephemeral ? messageOrInteraction : editReplyResponse; } else { this.response = await messageOrInteraction.reply({ ...(page as InteractionReplyOptions), fetchReply: true, ephemeral: false }); } } else { this.response = await messageOrInteraction.channel.send(page as BaseMessageOptions); } } /** * Handles the `collect` event from the collector. * @param targetUser The user the handler is for. * @param channel The channel the handler is running at. * @param interaction The button interaction that was received. */ protected async handleCollect( targetUser: User, channel: Message["channel"], interaction: ButtonInteraction | StringSelectMenuInteraction ): Promise { if (interaction.user.id === targetUser.id) { // Update the response to the latest interaction this.response = interaction; const action = this.actions.get(interaction.customId)! as any; if (actionIsButtonOrMenu(action)) { const previousIndex = this.index; await action.run({ interaction, handler: this, author: targetUser, channel, response: this.response!, collector: this.collector! }); if (!this.stopPaginatedMessageCustomIds.includes(action.customId)) { const newIndex = previousIndex === this.index ? previousIndex : this.index; const messagePage = await this.resolvePage(newIndex); const updateOptions = isFunction(messagePage) ? await messagePage(newIndex, this.pages, this) : messagePage; const _interaction = interaction; const messageComponents = await Promise.all( [...this.actions.values()].map>( async (interaction) => { if (isMessageButtonInteractionData(interaction)) { return new ButtonBuilder(interaction); } else { interaction; await this.setSelectOptions(_interaction as any, targetUser); return new StringSelectMenuBuilder({ options: this.selectStringOptions, placeholder: this.selectMenuPlaceholder, ...interaction }); } } ) ); updateOptions.components = createPartitionedMessageRow(messageComponents); await safelyReplyToInteraction({ messageOrInteraction: interaction, interactionEditReplyContent: updateOptions, interactionReplyContent: { ...this.#thisMazeWasNotMeantForYouContent, ephemeral: true }, componentUpdateContent: updateOptions }); } } } else { const interactionReplyOptions = await this.wrongUserInteractionReply( targetUser, interaction.user, this.resolvePaginatedMessageInternationalizationContext(interaction, targetUser) ); await interaction.reply( isObject(interactionReplyOptions) ? interactionReplyOptions : { content: interactionReplyOptions, ephemeral: true, allowedMentions: { users: [], roles: [] } } ); } } static defaultActions: PaginatorIntefaceAction[] = [ { customId: "@overwolf/paginated-messages.goToPage", type: ComponentType.StringSelect, run: ({ handler, interaction }) => { if (interaction.isStringSelectMenu()) { const value = interaction.values[0]; if (value.includes("/")) value.includes("-") ? handler.displaySubPage-- : handler.displaySubPage++; if (!value.includes("/")) handler.index = Number.parseInt(value, 10); // handler.setSelectOptions(interaction, interaction.user); } } }, { customId: "@overwolf/paginated-messages.firstPage", style: ButtonStyle.Primary, emoji: "⏪", type: ComponentType.Button, run: ({ handler }) => (handler.index = 0) }, { customId: "@overwolf/paginated-messages.previousPage", style: ButtonStyle.Primary, emoji: "◀️", type: ComponentType.Button, run: ({ handler }) => { if (handler.index === 0) { handler.index = handler.pages.length - 1; } else { --handler.index; } } }, { customId: "@overwolf/paginated-messages.nextPage", style: ButtonStyle.Primary, emoji: "▶️", type: ComponentType.Button, run: ({ handler }) => { if (handler.index === handler.pages.length - 1) { handler.index = 0; } else { ++handler.index; } } }, { customId: "@overwolf/paginated-messages.goToLastPage", style: ButtonStyle.Primary, emoji: "⏩", type: ComponentType.Button, run: ({ handler }) => (handler.index = handler.pages.length - 1) }, { customId: "@overwolf/paginated-messages.stop", style: ButtonStyle.Danger, emoji: "⏹️", type: ComponentType.Button, run: ({ collector }) => { collector.stop(); } } ]; static stopPaginatedMessageCustomIds = ["@over/paginated-messages.stop"]; } // eslint-disable-next-line @typescript-eslint/naming-convention export class _PaginatorEmbedInterface extends _PaginatorInterface { private embedTemplate: EmbedBuilder = new EmbedBuilder(); private totalPages = 0; private items: T[] = []; private itemsPerPage = 10; private fieldTitle = ""; /** * Set the items to paginate. * @param items The pages to set */ public setItems(items: T[]) { this.items = items; return this; } /** * Set the title of the embed field that will be used to paginate the items. * @param title The field title */ public setTitleField(title: string) { this.fieldTitle = title; return this; } /** * Sets the amount of items that should be shown per page. * @param itemsPerPage The number of items */ public setItemsPerPage(itemsPerPage: number) { this.itemsPerPage = itemsPerPage; return this; } /** * Sets the template to be used to display the embed fields as pages. This template can either be set from a template {@link EmbedBuilder} instance or an object with embed options. * * @param template EmbedBuilder * * @example * ```typescript * import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities'; * import { EmbedBuilder } from 'discord.js'; * * new PaginatedFieldMessageEmbed().setTemplate(new EmbedBuilder().setTitle('Test pager embed')).make().run(message); * ``` * * @example * ```typescript * import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities'; * * new PaginatedFieldMessageEmbed().setTemplate({ title: 'Test pager embed' }).make().run(message); * ``` */ public setTemplate(template: EmbedData | EmbedBuilder | ((embed: EmbedBuilder) => EmbedBuilder)) { this.embedTemplate = this.resolveTemplate(template); return this; } /** * Sets a format callback that will be mapped to each embed field in the array of items when the embed is paginated. This should convert each item to a format that is either text itself or can be serialized as text. * * @example * ```typescript * import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities'; * * new PaginatedFieldMessageEmbed() * .setTitleField('Test field') * .setTemplate({ embed }) * .setItems([ * { title: 'Sapphire Framework', value: 'discord.js Framework' }, * { title: 'Sapphire Framework 2', value: 'discord.js Framework 2' }, * { title: 'Sapphire Framework 3', value: 'discord.js Framework 3' } * ]) * .formatItems((item) => `${item.title}\n${item.value}`) * .make() * .run(message); * ``` * @param formatter The formatter callback to be applied to each embed item */ public formatItems(formatter: (item: T, index: number, array: T[]) => any) { this.items = this.items.map(formatter); return this; } /** * Build the pages of the given array. * * You must call the {@link PaginatedFieldMessageEmbed.make} and {@link PaginatedFieldMessageEmbed.run} methods last, in that order, for the pagination to work. * * @example * ```typescript * import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities'; * * new PaginatedFieldMessageEmbed() * .setTitleField('Test field') * .setTemplate({ embed }) * .setItems([ * { title: 'Sapphire Framework', value: 'discord.js Framework' }, * { title: 'Sapphire Framework 2', value: 'discord.js Framework 2' }, * { title: 'Sapphire Framework 3', value: 'discord.js Framework 3' } * ]) * .formatItems((item) => `${item.title}\n${item.value}`) * .make() * .run(message); * ``` */ public make() { if (this.fieldTitle.length === 0) throw new Error("The title of the field to format must have a value."); if (this.items.length === 0) throw new Error("The items array is empty."); if (this.items.some((x) => !x)) throw new Error("The format of the array items is incorrect."); this.totalPages = Math.ceil(this.items.length / this.itemsPerPage); this.generatePages(); return this; } private generatePages() { const template = this.embedTemplate instanceof EmbedBuilder ? this.embedTemplate.toJSON() : this.embedTemplate; for (let i = 0; i < this.totalPages; i++) { const clonedTemplate = new EmbedBuilder(template); const fieldsClone = this.embedTemplate.data.fields ?? []; clonedTemplate.data.fields = []; if (!clonedTemplate.data.color) clonedTemplate.setColor("Random"); const data = this.paginateArray(this.items, i, this.itemsPerPage); this.addPage({ embeds: [ clonedTemplate .addFields({ name: this.fieldTitle, value: data.join("\n"), inline: false }) .addFields(fieldsClone) ] }); } } private paginateArray(items: T[], currentPage: number, perPageItems: number) { const offset = currentPage * perPageItems; return items.slice(offset, offset + perPageItems); } private resolveTemplate(template: EmbedBuilder | EmbedData | ((embed: EmbedBuilder) => EmbedBuilder)) { if (template instanceof EmbedBuilder) { return template; } if (isFunction(template)) { return template(new EmbedBuilder()); } return new EmbedBuilder(template); } } type PaginatorIntefaceAction = | PaginatorIntefaceActionButton | PaginatorIntefaceActionLink | PaginatorIntefaceActionMenu; /** * To utilize buttons you can pass an object with the structure of {@link PaginatorIntefaceActionButton} to {@link PaginatorInteface} actions. * @example * ```typescript * const StopAction: PaginatorIntefaceActionButton { * customId: 'CustomStopAction', * emoji: '⏹️', * run: ({ collector }) => { * collector.stop(); * } * } * ``` */ interface PaginatorIntefaceActionButton extends InteractionButtonComponentData { run(context: PaginatorIntefaceActionContext): Awaitable; } /** * To utilize links you can pass an object with the structure of {@link PaginatorIntefaceActionLink} to {@link PaginatorInteface} actions. * @example * ```typescript * You can also give the object directly. * * const LinkSapphireJs: PaginatorIntefaceActionLink { * url: 'https://sapphirejs.dev', * label: 'Sapphire Website', * emoji: '🔗' * } * ``` */ type PaginatorIntefaceActionLink = LinkButtonComponentData; /** * To utilize Select Menus you can pass an object with the structure of {@link PaginatorIntefaceActionMenu} to {@link PaginatorInteface} actions. * @example * ```typescript * const StopAction: PaginatorIntefaceActionMenu { * customId: 'CustomSelectMenu', * type: Constants.MessageComponentTypes.SELECT_MENU, * run: ({ handler, interaction }) => interaction.isSelectMenu() && (handler.index = parseInt(interaction.values[0], 10)) * } * ``` */ interface PaginatorIntefaceActionMenu extends StringSelectMenuComponentData { run(context: PaginatorIntefaceActionContext): Awaitable; } /** * The context to be used in {@link PaginatorIntefaceActionButton}. */ interface PaginatorIntefaceActionContext { interaction: ButtonInteraction | StringSelectMenuInteraction; handler: _PaginatorInterface; author: User; channel: Message["channel"]; response: APIMessage | Message | CommandInteraction | StringSelectMenuInteraction | ButtonInteraction; collector: InteractionCollector; }