const {DISCORD_INVITES} = require("#constants"); const { createEmbed, page } = require("#utils/embed"); const { iconAsEmojiObject, icon, link, codeblock } = require("#utils/markdown"); const { editOrReply } = require("#utils/message"); const { STATIC_ASSETS } = require("#utils/statics"); 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 {DEFAULT_BUTTON_ICON_MAPPINGS, STACK_CACHE_KEYS, BuiltInButtonTypes, ResolveCallbackTypes} = require("./constants"); /** * Stores all active card stacks * @type {WeakMap} */ const activeStacks = new WeakMap(); /** * 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 Card Stack built-in navigation buttons * @param {Array} options.cards Root Card Stack * @param {Object} options.interactive Interactive Components * @param {Number} options.startingIndex Starting card index * @param {boolean} options.loop Wrap paging * @param {number} options.expires Timeout for the Card Stack listener. * @param {boolean} options.disableStackCache Allows disabling the stack result cache, meaning that every trigger will reevaluate a stack * @param {boolean} options.pageNumbers Renders Page Numbers in the footer of all embeds in cards. * @param {Function} options.pageNumberGenerator Function that renders a page number. Default style is `Page /` */ 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.loopPages = options.loop || true; this.expires = options.expires || 5*60*1000; this.pageNumbers = options.pageNumbers || true; this.pageNumberGenerator = options.pageNumberGenerator || ((pg)=>`Page ${pg.index + 1}/${pg.activeCardStack.length}`); this.rootIndex = this.index; this.stackCache = {}; this.pageState = []; this.currentSelectedSubcategory = null; /* this.lastInteraction = Date.now(); this.spawned = 0; */ this._spawn(); } /** * Kills the dynamic card stack. */ async kill(clearComponents){ clearTimeout(this.timeout); this.listener.clear(); if(clearComponents) await this._edit(this.getCurrentCard(), []) // Remove reference to free the cardstack for GC activeStacks.delete(this.context.message || this.context.interaction); } /** * Get a Stack from an attached reference (message/interaction). * @param {Message} ref Attached message/interaction * @returns {DynamicCardStack} */ _getStackByReference(ref){ return activeStacks.get(ref); } /** * Attaches a cardstack to its internal reference. * @private */ _createDynamicCardStack(){ // Kill any previously active cardstacks on this reference // (prevents oddities when editing a regular command) if(activeStacks.get(this.context.message || this.context.interaction)){ this._getStackByReference(this.context.message || this.context.interaction).kill(); } activeStacks.set(this.context.message || this.context.interaction, this); } /** * Cretaes a timeout for the paginator. * TODO: make this work properly * @returns {number} Timeout * @private */ _createTimeout(){ return setTimeout(()=>{ /* // This currently isn't doable with our Components listener. if(this.spawned - this.lastInteraction <= this.expires){ clearTimeout(this.timeout) this.spawned = Date.now(); // New expiry time is 30 seconds this.expires = 30*1000; this.timeout = this._createTimeout(); } else { this.kill(true); }*/ this.kill(true); }, this.expires) } /** * Creates a new cardstack in the given channel */ _spawn(){ try{ this._createDynamicCardStack(this.context.client); this.activeCardStack = [...this.cards]; this.updatePageState() // Create internal component listener this.listener = new Components({ timeout: this.expires, run: this._handleInteraction.bind(this), onError: (e)=>{ console.log(e) } }) this.timeout = this._createTimeout() //this.spawned = Date.now() return this._edit({ ...this.getCurrentCard(), components: this.listener }); }catch(e){ console.log(e) } } /** * Resolves page state for all root stack cards. */ updatePageState(){ let i = 0; this.pageState = []; for(const ac of this.cards){ if(ac["_meta"]){ this.pageState[i] = Object.assign({}, ac["_meta"]); } i++; } } /** * Gets a card from the currently active * stack by its index * @param index Page Index * @returns {*} */ getCardByIndex(index){ try{ // TODO: remove this some time after launch let card = structuredClone(this.activeCardStack[index]); // This creates an error card with debug information // in case that our activeCardStack gets corrupted // or lost somehow (bad implementation) if(!this.activeCardStack[index]) card = page(createEmbed("errordetail", this.context, { error: "Unable to resolve card.", content: `Index: \`${this.index}\`, Stack Size: \`${this.index}\`\n` + (Object.keys(this.getAllStateForPage(this.index)).length >= 1 ? codeblock("json", [JSON.stringify(this.getAllStateForPage(this.index), null, 2)]).substr(0, 5000) : "") })) if(!card.content) card.content = ""; card.content += `\n-# ${icon("flask_mini")} You are using the new page system • Leave feedback or report bugs in our ${link(DISCORD_INVITES.feedback_cardstack, "Discord Server", "labsCore Support", false)}!` // Render Page Numbers. // Conditions: // - We have more than one card in the active stack // - We have embeds in the stack if(this.pageNumbers && card.embeds?.length && this.activeCardStack.length >= 2){ card.embeds = card.embeds.map((e)=>{ if(!e.footer) e.footer = { text: this.pageNumberGenerator(this) } else { if(e.footer.text) e.footer.text += ` • ${this.pageNumberGenerator(this)}`; else e.footer.text = this.pageNumberGenerator(this); } return e; }) } return card; }catch(e){ console.log(e) return page(createEmbed("errordetail", this.context, { error: "Unable to render card:", content: codeblock("js",[(e ? e.stack || e.message : e).replaceAll(process.cwd(), '')]) })) } } /** * Advances the index and returns the next card from the stack. * @returns {Message} Card */ nextCard(){ 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; return Object.assign(this.getCardByIndex(this.index), { components: this._renderComponents() }); } /** * Decreases the index and returns the next card from the stack. * @returns {Message} Card */ 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.getCardByIndex(this.index), { components: this._renderComponents() }); } /** * Edits the cardstack message. * Automatically applies and rerenders components. * @param {Message} cardContent Card Content * @param {boolean, Array} components Custom Components Array */ async _edit(cardContent, components = false){ let message = Object.assign({}, cardContent); if(!components){ this.listener.components = this._renderComponents(); message.components = this.listener; } else { message.components = components; } if(message["_meta"]) delete message["_meta"]; try{ return editOrReply(this.context, { ...message, reference: true, allowedMentions: {parse: [], repliedUser: false} }) }catch(e){ console.log(e) } } /** * Returns the currently selected card from the * active stack. * @returns {Message} Card */ getCurrentCard(){ return this.getCardByIndex(this.index) } /** * Retrieves state from the currently active root card * @param {String} key */ getState(key){ if(!this.pageState[this.rootIndex]) return null; if(!this.pageState[this.rootIndex][key]) return null; return this.pageState[this.rootIndex][key]; } /** * Returns all page state. * Only really intended for debugging purposes. * @returns {Object} */ getAllState(){ return this.pageState; } /** * Returns all state for a specific page. * Only really intended for debugging purposes. * @returns {Object} */ getAllStateForPage(index){ return this.pageState[index] || {}; } /** * 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){ let btn = { type: MessageComponentTypes.BUTTON, customId: b, style: 2, disabled: this.activeCardStack.length === 1 || disabled, 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 } // Dynamic disabling if(!disabled && button.condition && typeof(button.condition) == "function") component.disabled = !button.condition(this); // Dynamic label 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 // Change color if this is the active button. // TODO: allow overwriting the "active" color if(this.currentSelectedSubcategory === b) component.style = 1; // Insert the component at the correct slot. if(button.inline){ // Ensure there is enough space for an inline component. if(nComponents.components.length >= 5){ // Ensure there is space on secondary rows. if(nComponentsSecondary[nComponentsSecondary.length - 1].components.length >= 5) nComponentsSecondary.push(new ComponentActionRow({})) nComponentsSecondary[nComponentsSecondary.length - 1].addButton(component); } else { nComponents.addButton(component); } } else { // Ensure there is space on secondary rows to insert // the component. if(nComponentsSecondary[nComponentsSecondary.length - 1].components.length >= 5) nComponentsSecondary.push(new ComponentActionRow({})) nComponentsSecondary[nComponentsSecondary.length - 1].addButton(component); } } if(nComponentsSecondary[0].components.length >= 1) return [nComponents, ...nComponentsSecondary] return [nComponents]; } /** * Compute Cache * * The compute cache allows storing computed values * (i.e. resulting card stacks) in order to skip * refetching or reprocessing substacks when not * necessary. The cache can be disabled per-component. */ /** * Set an internal cached computed value. * @param index Root Card Index * @param componentId Component ID * @param key Cache Key * @param value Cache Data * @private */ _setCachedValue(index, componentId, key, value){ if(!this.stackCache[index]) this.stackCache[index] = {}; if(!this.stackCache[index][componentId]) this.stackCache[index][componentId] = {}; this.stackCache[index][componentId][key] = value; } /** * Retrieve an internal cached computed value. * @param index Root Card Index * @param componentId Component ID * @param key Cache Key * @returns {*|null} Cached Data * @private */ _getCachedValue(index, componentId, key){ if(this.interactive_components[componentId].disableCache) return null; if(!this.stackCache[index]) return null; if(!this.stackCache[index][componentId]) return null; if(!this.stackCache[index][componentId][key]) return null; return this.stackCache[index][componentId][key]; } /** * Handles an interaction from the attached components. * @param {ComponentContext} ctx */ async _handleInteraction(ctx){ if(ctx.user.id !== this.context.user.id) return ctx.respond({ type: InteractionCallbackTypes.DEFERRED_UPDATE_MESSAGE }) //this.lastInteraction = Date.now(); // Built-in Buttons if(Object.values(BuiltInButtonTypes).includes(ctx.data.customId)){ switch(ctx.data.customId){ case "next": return ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, data: this.nextCard() }) case "previous": return ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, data: this.previousPage() }) default: console.error("unknown button??") } return; } // Interactive Components if(this.interactive_components[ctx.data.customId]){ // If the selected button is already active, disable it // and restore the root stack at its previous index. if(this.currentSelectedSubcategory === ctx.data.customId){ this.activeCardStack = [...this.cards]; this.index = this.rootIndex; this.currentSelectedSubcategory = null; return await ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, data: Object.assign(this.getCurrentCard(), { components: this._renderComponents(false)}) }) } else this.currentSelectedSubcategory = ctx.data.customId; let resolveTime = Date.now(); try{ // If we have a cached result, retrieve it if(this._getCachedValue(this.rootIndex, ctx.data.customId, STACK_CACHE_KEYS.RESULT_CARDS) !== null){ this.activeCardStack = [...this._getCachedValue(this.rootIndex, ctx.data.customId, STACK_CACHE_KEYS.RESULT_CARDS)]; await ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, data: Object.assign(this.getCurrentCard(), {components:this._renderComponents(false)}) }) return; } else { // Controls if we should display a loading (skeleton) embed while the // new stack is being fetched/rendered. Instant results should only // ever be used if we rely on local data or can guarantee almost-instant // processing/fetching times. if(!this.interactive_components[ctx.data.customId].instantResult) { let processingEmbed = page(createEmbed("default", ctx, { image: { url: STATIC_ASSETS.card_skeleton } })) // Render a custom loading skeleton embed // TODO: maybe allow several loading modes here // i.e COPY_PARENT which will copy select fields // from the parent embed or SKELETON_WITH_TITLE. // -> needs iterating on visual language first 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)}) }) } // Compute the active cardstack. let resolvedNewStack = await this.interactive_components[ctx.data.customId].resolvePage(this); if(!Object.values(ResolveCallbackTypes).includes(resolvedNewStack.type)) throw new Error(`Invalid Stack Resolve Type (${resolvedNewStack.type})`); switch(resolvedNewStack.type){ /** * SUBSTACK * * Replace the currently active paging * with a new, separate card stack to * page through. */ case ResolveCallbackTypes.SUBSTACK: this.activeCardStack = resolvedNewStack.cards; this.index = resolvedNewStack.index || 0; // Cache the computed cardstack for future accessing. // The cache can be disabled/bypassed if we either // a) have really big/complex results // b) want to ensure data is always fresh // We currently only cache SUBSTACK responses, as the other // types probably need revalidating/refetching since the parent // has changed and might carry new data. if(!this.interactive_components[ctx.data.customId].disableCache){ this._setCachedValue(this.rootIndex, ctx.data.customId, STACK_CACHE_KEYS.RESULT_CARDS, [...this.activeCardStack]); } break; /** * REPLACE_PARENT_CARD * * Replaces the parent card (the one this action * was initiated from) with a new one. * * Re-resolves all page state. * Unselects the button. */ case ResolveCallbackTypes.REPLACE_PARENT_CARD: this.cards[this.rootIndex] = resolvedNewStack.card; this.activeCardStack = [...this.cards]; this.updatePageState(); this.index = resolvedNewStack.index || this.rootIndex; this.currentSelectedSubcategory = null; break; /** * REPLACE_STACK * * Replaces the entire parent * card stack with a new set. * * Re-resolves all page state. * Unselects the button. */ case ResolveCallbackTypes.REPLACE_STACK: this.activeCardStack = resolvedNewStack.cards; this.updatePageState(); this.index = resolvedNewStack.index || this.rootIndex; this.currentSelectedSubcategory = null; break; } } } catch(e){ // Display an error if we're NOT // in the root stack (that would break // things badly). if(this.currentSelectedSubcategory != null) this.activeCardStack = [ page(createEmbed("errordetail", ctx, { error: "Card stack rendering failed.", content: codeblock("js",[(e ? e.stack || e.message : e).replaceAll(process.cwd(), '')]) })) ] console.log("resolve failed:") console.log(e) } // Update the card stack with a card from the new stack. if(this.interactive_components[ctx.data.customId].instantResult){ await ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, data: Object.assign(this.getCurrentCard(), { components: this._renderComponents()}) }) } else { // This timeout exists 1. for cosmetic reasons so people can // see the skeleton state and 2. in order to avoid a really // annoying race condition with the media proxy reverting our // embed to a prior state. // If we've already waited at least 2 seconds during processing // it *should* be safe to just edit the message now. if((Date.now() - resolveTime) < 2000){ setTimeout(()=>{ return this._edit(Object.assign(this.getCurrentCard(), {components:this._renderComponents()})) }, 1500) } else { await this._edit(Object.assign(this.getCurrentCard(), {components:this._renderComponents()})) } } return; } console.error("Unknown button was triggered on stack: " + ctx.data.customId); } } module.exports.DynamicCardStack = DynamicCardStack;