const { iconAsEmojiObject } = require("#utils/markdown"); const { editOrReply } = require("#utils/message"); const { Context } = require("detritus-client/lib/command"); const { MessageComponentTypes, InteractionCallbackTypes } = require("detritus-client/lib/constants"); const { Message } = require("detritus-client/lib/structures"); const { ComponentContext, Components, ComponentActionRow} = require("detritus-client/lib/utils"); const {createEmbed, page} = require("#utils/embed"); const activeStacks = new Map(); const DEFAULT_BUTTON_ICON_MAPPINGS = Object.freeze({ "next": "button_chevron_right", "previous": "button_chevron_left" }); const SUBCATEGORY_STATE_TYPES = Object.freeze({ NONE: 0, SINGLE_PAGE: 1, MULTI_PAGE: 2, }); /** * DynamicCardStack represents an interactive stacks * of cards (embeds) for the user to paginate through * or interact with. */ class DynamicCardStack { /** * Creates a new DynamicCardStack * @param {Context} context Context * @param {Object} options DynamicCardStack Arguments * @param {Array} options.buttons CardStack built-in navigation buttons * @param {Array} options.cards Baseline CardStack * @param {Object} options.interactive Interactive Components * @param {Number} options.startingIndex Starting card index * @param {boolean} options.loop Wrap paging */ constructor(context, options){ this.context = context; this.cards = options.cards || []; this.buttons = options.buttons || ["previous","next"] this.interactive_components = options.interactive || {}; this.index = options.startingIndex || 0; this.rootIndex = this.index; this.loopPages = options.loop || true; this.pageState = []; this.subcategoryState = SUBCATEGORY_STATE_TYPES.SINGLE_PAGE; this.currentSelectedSubcategory = null; console.log("now spawning") this._spawn(); } /** * Kills the dynamic card stack. */ kill(){ clearTimeout(this.timeout); // Remove reference to free the paginator for GC activeStacks.delete(this.context.message?.id); } /** * Get a Stack based on its attached message ID * @param {*} id Attached message ID * @returns {DynamicCardStack} */ _getStackByMessageId(id){ return activeStacks.get(id); } _createDynamicCardStack(){ // Kill any previously active paginators if(activeStacks.get(this.context.message?.id)){ this._getStackByMessageId(this.context.message?.id).kill(); } activeStacks.set(this.context.message?.id, this); } /** * Creates a new paginator in the given channel */ _spawn(){ try{ this._createDynamicCardStack(this.context.client); this.activeCardStack = [...this.cards]; // Resolve page state before let i = 0; for(const ac of this.cards){ if(ac["_meta"]){ console.log(ac) this.pageState[i] = Object.assign({}, ac["_meta"]); } i++; } // Create internal component listener this.listener = new Components({ timeout: this.expires, run: this._handleInteraction.bind(this), onError: (e)=>{ console.log(e) } }) return this._edit({ ...this.getCurrentCard(), components: this.listener }, true); }catch(e){ console.log(e) } } getPageByIndex(index){ return this.activeCardStack[index]; } nextPage(){ this.index = this.index + 1; if(this.index >= this.activeCardStack.length){ if(this.loopPages) this.index = 0; } if(this.currentSelectedSubcategory == null) this.rootIndex = this.index; console.log("new index: " + this.index) return Object.assign(this.getPageByIndex(this.index), { components: this._renderComponents() }); } previousPage(){ this.index = this.index - 1; if(this.index < 0){ if(this.loopPages) this.index = this.activeCardStack.length - 1; else this.index = 0; } if(this.currentSelectedSubcategory == null) this.rootIndex = this.index; return Object.assign(this.getPageByIndex(this.index), { components: this._renderComponents() }); } currentPage() { return Object.assign(this.getPageByIndex(this.index), { components: this._renderComponents() }); } /** * Edits the cardstack message. * Automatically applies and rerenders components. * @param {Message} cardContent Card Content */ async _edit(cardContent){ let message = Object.assign({}, cardContent); this.listener.components = this._renderComponents(); message.components = this.listener; if(message["_meta"]) delete message["_meta"]; console.log("GOING OUT:") console.log(JSON.stringify(message, null, 2)) try{ return editOrReply(this.context, { ...message, reference: true, allowedMentions: {parse: [], repliedUser: false} }) }catch(e){ console.log(e) } } getCurrentCard(){ console.log(this.activeCardStack[this.index]) return this.activeCardStack[this.index]; } getPageIndex(){ return this.index; } // API for contextual buttons /** * Retrieves state from the currently active page * @param {String} key */ getState(key){ if(!this.pageState[this.rootIndex]) return null; return this.pageState[this.rootIndex][key]; } getAllState(){ return this.pageState; } /** * Renders components and button states */ _renderComponents(disabled = false){ let nComponents = new ComponentActionRow({}) let nComponentsSecondary = new ComponentActionRow({}) // First Row always starts with built-in components for(const b of this.buttons){ console.log("len: " + this.activeCardStack.length) console.log(this.activeCardStack) let btn = { type: MessageComponentTypes.BUTTON, customId: b, style: 2, disabled: this.activeCardStack.length === 1 || disabled || (this.subcategoryState !== SUBCATEGORY_STATE_TYPES.SINGLE_PAGE), emoji: iconAsEmojiObject(DEFAULT_BUTTON_ICON_MAPPINGS[b]) } nComponents.addButton(btn) } for(const b of Object.keys(this.interactive_components)){ let button = this.interactive_components[b]; // Validate if the component should be visible on this page. // If a function is provided we need to execute it. if(typeof(button.visible) === "boolean" && button.visible === false) continue; else if(typeof(button.visible) === "function" && !button.visible(this)) continue; let component = { type: MessageComponentTypes.BUTTON, customId: b, style: button.style || 2, disabled: disabled } if(button.label){ if(typeof(button.label) === "function") component.label = button.label(this); else component.label = button.label; } if(button.icon) component.emoji = iconAsEmojiObject(button.icon) || undefined // Display the selected button if(this.currentSelectedSubcategory === b) component.style = 1; if(button.inline) nComponents.addButton(component); else nComponentsSecondary.addButton(component); } console.log(JSON.stringify(nComponents)) if(nComponentsSecondary.components.length >= 1) return [nComponents, nComponentsSecondary] return [nComponents]; } /** * Handles an interaction from the attached components * @param {ComponentContext} ctx */ async _handleInteraction(ctx){ console.log(ctx.data.customId) // should be a built-in button if(["next","previous"].includes(ctx.data.customId)){ console.log("triggering button") switch(ctx.data.customId){ case "next": return ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, data: this.nextPage() }) case "previous": return ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, data: this.previousPage() }) default: console.error("unknown button??") } return; } // interactive buttons if(this.interactive_components[ctx.data.customId]){ if(this.currentSelectedSubcategory === ctx.data.customId){ console.log("restoring state") this.activeCardStack = [...this.cards]; this.index = this.rootIndex; this.currentSelectedSubcategory = null; return await ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, data: Object.assign(this.currentPage(), { components: this._renderComponents(false)}) }) } else this.currentSelectedSubcategory = ctx.data.customId; this.cachedIndex = this.index; let processingEmbed = page(createEmbed("default", ctx, { "description": "looading..." })) if(this.interactive_components[ctx.data.customId].renderLoadingState) processingEmbed = page(this.interactive_components[ctx.data.customId].renderLoadingState(this)); await ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, data: Object.assign(processingEmbed, { components: this._renderComponents(true)}) }) console.log("resolve trigger") try{ this.activeCardStack = await this.interactive_components[ctx.data.customId].resolvePage(this); } catch(e){ console.log("resolve failed:") console.log(e) } console.log("resolve post") // TODO: allow overriding index this.index = 0; console.log("stack resolved") setTimeout(()=>{ console.log(this.activeCardStack) return this._edit(Object.assign(this.currentPage(), {components:this.listener}), true) }, 1500) return; } console.error("Unknown button was triggered on stack: " + ctx.data.customId); } } module.exports = DynamicCardStack