import {EventHandler} from "../Events/EventHandler"; import {Container} from "../Container/Container"; import {AnyMenuItem, MenuAction, MenuItemType, TraversalPath} from "./MenuRenderer.types"; import {randomUUID} from "node:crypto"; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction, EmbedBuilder, MessageComponentInteraction, MessageFlags } from "discord.js"; import {Nullable} from "../types/Nullable"; import {IconCache} from "../Icons/IconCache"; import {MessageActionRowComponentBuilder} from "@discordjs/builders"; import {ComponentInteractionEvent} from "../Events/EventClasses/ComponentInteractionEvent"; import {MenuTraversal} from "./MenuTraversal"; import {Prompt} from "./Modals/Prompt"; import _ from "lodash"; export class MenuRenderer { private readonly menuId: string; private eventId: Nullable; private exitButton: ButtonBuilder; private backButton: ButtonBuilder; private static MAX_BUTTON_PER_ROW = 5; private static MAX_ROWS = 5; private static SYSTEM_ROW_COUNT = 1; private static MAX_USER_ROW_COUNT = MenuRenderer.MAX_ROWS - MenuRenderer.SYSTEM_ROW_COUNT; constructor( private readonly traversal: MenuTraversal, private readonly eventHandler: EventHandler|null = null, private readonly iconCache: Nullable = null ) { this.eventHandler ??= Container.get(EventHandler.name); this.iconCache ??= Container.get(IconCache.name); this.menuId = randomUUID(); this.exitButton = new ButtonBuilder() .setLabel("Exit") .setStyle(ButtonStyle.Danger) .setCustomId(this.getInteractionId("EXIT")) .setEmoji(this.iconCache?.get("door_open_solid_white") ?? ''); this.backButton = new ButtonBuilder() .setLabel("Back") .setStyle(ButtonStyle.Secondary) .setCustomId(this.getInteractionId("MOVE", "BACK")) .setEmoji(this.iconCache?.get("angle_left_solid") ?? ''); } public async display(interaction: CommandInteraction) { this.eventId = this.eventHandler?.addHandler(ComponentInteractionEvent.name, { method: this.handleUIEvents.bind(this), }); await interaction.reply({ content: "", components: this.getActionRows(), embeds: [this.getEmbed()], withResponse: true, flags: MessageFlags.Ephemeral }) } public close() { this.eventHandler?.removeHandler(ComponentInteractionEvent.name, this.eventId ?? ''); } private getActionRows(): ActionRowBuilder[] { const navigation = new ActionRowBuilder() if (!this.traversal.isRoot) { navigation.addComponents(this.backButton); } const rows = [ ...this.getComponentForMenuItem(this.traversal.currentMenuItem), navigation ]; return rows.filter(row => row.components.length > 0); } private getComponentForMenuItem(menuItem: AnyMenuItem): ActionRowBuilder[] { if (menuItem.type === MenuItemType.Collection) { const rows = _.chunk(menuItem.children, MenuRenderer.MAX_BUTTON_PER_ROW); if (rows.length > MenuRenderer.MAX_USER_ROW_COUNT) { throw new TypeError( `A collection can only have a max of ${MenuRenderer.MAX_USER_ROW_COUNT * MenuRenderer.MAX_BUTTON_PER_ROW} entries!` ); } return rows .map((items) => new ActionRowBuilder() .setComponents( ...items.map(item => new ButtonBuilder() .setLabel(item.label) .setStyle(ButtonStyle.Primary) .setCustomId(this.getInteractionId("MOVE", item.traversalKey)) .setEmoji(this.iconCache?.get(item.type === MenuItemType.Field ? 'pen_solid' : "folder_solid") ?? '') ) ) ) } if (menuItem.type === MenuItemType.Field) { const action = menuItem.getActionRowBuilder({ path: this.traversal.path }) action.setCustomId(this.getInteractionId("SET", this.traversal.stringifiedPath)); return [new ActionRowBuilder().setComponents([action])]; } return [new ActionRowBuilder()]; } private getEmbed(): EmbedBuilder { const embed = new EmbedBuilder() .setTitle(this.traversal.currentMenuItem.label) .setDescription(this.traversal.currentMenuItem.description ?? '') .setAuthor({ name: "/ " + this.traversal.path.join(' / ') }); if (this.traversal.currentMenuItem.type === MenuItemType.Field) { const currentValue = this.traversal.currentMenuItem.getCurrentValue({ path: this.traversal.path }); embed.addFields({ name: "Current Value", value: currentValue }); } return embed; } private getInteractionId(action: MenuAction, parameter: Nullable = undefined): string { return `${this.menuId};${action};${parameter ?? ''}` } private async handleUIEvents(ev: ComponentInteractionEvent) { if (!ev.interaction.customId.startsWith(this.menuId)) { return; } ev.acknowledge(); const [, action, parameter ] = ev.interaction.customId.split(';') const menuAction = action; switch (menuAction) { case "MOVE": if (parameter === 'BACK') { this.traversal.travelBack(); break; } this.traversal.travelForward(parameter); break; case "SET": { const value = ev.interaction.isAnySelectMenu() ? ev.interaction.values : [""]; const menuItem = this.traversal.getMenuItem(parameter); if (menuItem.type !== MenuItemType.Field) { break; } menuItem.setValue(value, { path: MenuTraversal.unstringifyTraversalPath(parameter) }) break; } } let updateInteraction: unknown = ev.interaction; if (this.traversal.currentMenuItem.type === MenuItemType.Prompt) { const prompt = new Prompt(this.eventHandler); const currentValue = this.traversal.currentMenuItem; const currentValuePath = [...this.traversal.path]; const row = currentValue.getActionRowBuilder({ path: currentValuePath }); row.setLabel(currentValue.label); row.setValue(currentValue.getCurrentValue({path: currentValuePath})); row.setPlaceholder(currentValue.description ?? ''); this.traversal.travelBack(); const value = await prompt.requestValue( this.traversal.currentMenuItem.label, row, ev.interaction ); currentValue.setValue( value.value, { path: currentValuePath } ) updateInteraction = value.interaction; } updateInteraction.update({ components: this.getActionRows(), embeds: [ this.getEmbed() ] }) } }