diff --git a/commands/message/dev/test.js b/commands/message/dev/test.js index a1eac10..c4e163d 100644 --- a/commands/message/dev/test.js +++ b/commands/message/dev/test.js @@ -2,6 +2,7 @@ const { createEmbed, page } = require("#utils/embed"); const { acknowledge } = require("#utils/interactions"); const { DynamicCardStack } = require("../../../labscore/cardstack/DynamicCardStack"); +const {CARD_STACK_CONSTANTS} = require("#cardstack"); module.exports = { label: "text", @@ -25,6 +26,9 @@ module.exports = { createEmbed("default", context, { description: "page 1"}), createEmbed("default", context, { description: "page 2. this has a conditional button."}) ].map((p, index)=>page(p, {}, { key: `t_${index}` })), + pageNumberGenerator: (pg)=>{ + return `Test ${pg.index}` + }, interactive: { always_active_button: { label: "single sub page", @@ -32,9 +36,12 @@ module.exports = { visible: true, disableCache: true, resolvePage: ()=>{ - return [ - createEmbed("success", context, "smiley") - ].map((p)=>page(p)) + return { + type: CARD_STACK_CONSTANTS.RESOLVE_CALLBACK_TYPES.SUBSTACK, + cards: [ + createEmbed("success", context, "smiley") + ].map((p)=>page(p)) + } } }, conditional_button: { @@ -46,16 +53,20 @@ module.exports = { return (page.getState("key") === "t_1") }, resolvePage: (pg) => { - return [ - createEmbed("default", context, { description: "this is a conditional sub page"}), - createEmbed("default", context, { description: "this is a conditional sub page two"}) - ].map((p)=>page(p)); + return { + type: CARD_STACK_CONSTANTS.RESOLVE_CALLBACK_TYPES.SUBSTACK, + cards: [ + createEmbed("default", context, { description: "this is a conditional sub page"}), + createEmbed("default", context, { description: "this is a conditional sub page two"}) + ].map((p)=>page(p)) + } } }, dynamic_button: { // Button Label label: (page) => { - return page.getState("key"); + console.log(page.getState("key")) + return page.getState("key") || "test"; }, // Next to pagination or new row inline: false, @@ -63,67 +74,19 @@ module.exports = { // Renders the loading state card renderLoadingState: (page) => { return createEmbed("default", context, { - description: "-# Subpage Loading :)", + description: "-# replacing papa card", }) }, resolvePage: async (pg) => { console.log("resolving page") - return [ - createEmbed("default", context, { description: "this is a dynamic sub page " + Math.random()}), - createEmbed("default", context, { description: "this is a dynamic sub page " + Math.random()}), - createEmbed("default", context, { description: "this is a dynamic sub page " + Math.random()}), - createEmbed("default", context, { description: "this is a dynamic sub page " + Math.random()}) - ].map((p)=>page(p)); + return { + type: CARD_STACK_CONSTANTS.RESOLVE_CALLBACK_TYPES.REPLACE_PARENT_CARD, + card: page(createEmbed("default", context, { description: "this is the new over lord " + new Date()}), {}, { + key: Date.now() + }) + }; } - }, - conditional_button_2: { - // Button Label - label: "Conditional", - // Next to pagination or new row - inline: false, - visible: true, - resolvePage: (pg) => {throw "a"} - }, - conditional_button_3: { - // Button Label - label: "Conditional", - // Next to pagination or new row - inline: false, - visible: true, - resolvePage: (pg) => {throw "a"} - }, - conditional_button_4: { - // Button Label - label: "Conditional", - // Next to pagination or new row - inline: false, - visible: true, - resolvePage: (pg) => {throw "a"} - }, - conditional_button_5: { - // Button Label - label: "Conditional", - // Next to pagination or new row - inline: false, - visible: true, - resolvePage: (pg) => {throw "a"} - }, - conditional_button_6: { - // Button Label - label: "Conditional", - // Next to pagination or new row - inline: false, - visible: true, - resolvePage: (pg) => {throw "a"} - }, - conditional_button_7: { - // Button Label - label: "Conditional", - // Next to pagination or new row - inline: false, - visible: true, - resolvePage: (pg) => {throw "a"} - }, + } } }) }catch(e){ diff --git a/commands/message/search/anime.js b/commands/message/search/anime.js index 2523596..ee73f08 100644 --- a/commands/message/search/anime.js +++ b/commands/message/search/anime.js @@ -1,10 +1,10 @@ const { anime, animeSupplemental} = require('#api'); const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES, COLORS_HEX} = require('#constants'); -const { createDynamicCardStack } = require("#cardstack"); +const { createDynamicCardStack, CARD_STACK_CONSTANTS } = require("#cardstack"); const { hexToDecimalColor } = require("#utils/color"); -const { createEmbed, formatPaginationEmbeds, page } = require('#utils/embed'); +const { createEmbed, page } = require('#utils/embed'); const { acknowledge } = require('#utils/interactions'); const { smallPill, link, pill, stringwrapPreserveWords, timestamp, TIMESTAMP_FLAGS} = require('#utils/markdown'); const { editOrReply } = require('#utils/message'); @@ -106,7 +106,7 @@ module.exports = { if(!pages.length) return editOrReply(context, createEmbed("warning", context, `No results found.`)) createDynamicCardStack(context, { - cards: formatPaginationEmbeds(pages), + cards: pages, interactive: { episodes_button: { label: "Episodes", @@ -152,7 +152,15 @@ module.exports = { return page(card) }) - return formatPaginationEmbeds(cards); + return { + type: CARD_STACK_CONSTANTS.RESOLVE_CALLBACK_TYPES.SUBSTACK, + cards: cards.length >= 1 ? cards : [ + // This happens if the episode metadata resolver fails. + page(createEmbed("defaultNoFooter", context, { + description: `-# ${pg.getState("name")} › **Episodes**\n## Episodes Unavailable\n\nWe're unable to display episode details for this content.` + })) + ], + }; } }, characters_button: { @@ -190,7 +198,10 @@ module.exports = { return page(card) }) - return formatPaginationEmbeds(cards); + return { + type: CARD_STACK_CONSTANTS.RESOLVE_CALLBACK_TYPES.SUBSTACK, + cards: cards + }; } }, related_button: { @@ -214,9 +225,12 @@ module.exports = { let cards = episodes.response.body.relations.map((e) => renderAnimeResultsPage(context, e, false)) - return formatPaginationEmbeds(cards); + return { + type: CARD_STACK_CONSTANTS.RESOLVE_CALLBACK_TYPES.SUBSTACK, + cards: cards + }; } - }, + } } }); }catch(e){ diff --git a/labscore/cardstack/Constants.js b/labscore/cardstack/Constants.js new file mode 100644 index 0000000..998e2d4 --- /dev/null +++ b/labscore/cardstack/Constants.js @@ -0,0 +1,33 @@ + +module.exports.BUILT_IN_BUTTON_TYPES = Object.freeze({ + NEXT_PAGE: "next", + PREVIOUS_PAGE: "previous" +}) + +module.exports.DEFAULT_BUTTON_ICON_MAPPINGS = Object.freeze({ + [this.BUILT_IN_BUTTON_TYPES.NEXT_PAGE]: "button_chevron_right", + [this.BUILT_IN_BUTTON_TYPES.PREVIOUS_PAGE]: "button_chevron_left" +}); + +module.exports.STACK_CACHE_KEYS = Object.freeze({ + RESULT_CARDS: 0 +}) + +/** + * Callback Types for a Dynamic Card Stack + * Component resolve. + * + * - `SUBSTACK` - Creates a "submenu" with a brand new cardstack + * - `REPLACE_PARENT` - Replaces the parent card in the root stack + * - This callback type will also unselect the button + * - `REPLACE_ROOT_STACK` - Replaces the root stack + * - This callback type will also unselect the button + */ +module.exports.RESOLVE_CALLBACK_TYPES = Object.freeze({ + SUBSTACK: 0, + REPLACE_PARENT_CARD: 1, + REPLACE_STACK: 2, + 0: "SUBSTACK", + 1: "REPLACE_PARENT_CARD", + 2: "REPLACE_STACK", +}) \ No newline at end of file diff --git a/labscore/cardstack/DynamicCardStack.js b/labscore/cardstack/DynamicCardStack.js index 5b1ebb1..5be1cda 100644 --- a/labscore/cardstack/DynamicCardStack.js +++ b/labscore/cardstack/DynamicCardStack.js @@ -1,5 +1,5 @@ const { createEmbed, page } = require("#utils/embed"); -const { iconAsEmojiObject, icon, link} = require("#utils/markdown"); +const { iconAsEmojiObject, icon, link, codeblock } = require("#utils/markdown"); const { editOrReply } = require("#utils/message"); const { STATIC_ASSETS } = require("#utils/statics"); @@ -8,6 +8,7 @@ const { MessageComponentTypes, InteractionCallbackTypes } = require("detritus-cl const { Message } = require("detritus-client/lib/structures"); const { ComponentContext, Components, ComponentActionRow} = require("detritus-client/lib/utils"); const {DISCORD_INVITES} = require("#constants"); +const {DEFAULT_BUTTON_ICON_MAPPINGS, STACK_CACHE_KEYS, BUILT_IN_BUTTON_TYPES, RESOLVE_CALLBACK_TYPES} = require("./Constants"); /** * Stores all active card stacks @@ -15,20 +16,6 @@ const {DISCORD_INVITES} = require("#constants"); */ const activeStacks = new WeakMap(); -const BUILT_IN_BUTTON_TYPES = Object.freeze({ - NEXT_PAGE: "next", - PREVIOUS_PAGE: "previous" -}) - -const DEFAULT_BUTTON_ICON_MAPPINGS = Object.freeze({ - [BUILT_IN_BUTTON_TYPES.NEXT_PAGE]: "button_chevron_right", - [BUILT_IN_BUTTON_TYPES.PREVIOUS_PAGE]: "button_chevron_left" -}); - -const STACK_CACHE_KEYS = Object.freeze({ - RESULT_CARDS: 0 -}) - /** * DynamicCardStack represents an interactive stacks * of cards (embeds) for the user to paginate through @@ -39,13 +26,15 @@ 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 {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 CardStack timeout + * @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; @@ -54,9 +43,12 @@ class DynamicCardStack { 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.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 = []; @@ -138,15 +130,7 @@ class DynamicCardStack { this.activeCardStack = [...this.cards]; - // Resolve page state for all - // root pages. - let i = 0; - for(const ac of this.cards){ - if(ac["_meta"]){ - this.pageState[i] = Object.assign({}, ac["_meta"]); - } - i++; - } + this.updatePageState() // Create internal component listener this.listener = new Components({ @@ -170,6 +154,20 @@ class DynamicCardStack { } } + /** + * 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 @@ -177,11 +175,46 @@ class DynamicCardStack { * @returns {*} */ getCardByIndex(index){ - // TODO: remove this some time after launch - let card = Object.assign({}, this.activeCardStack[index]) - 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)}!` - return card; + 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(), '')]) + })) + } } /** @@ -270,6 +303,16 @@ class DynamicCardStack { 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 */ @@ -433,8 +476,7 @@ class DynamicCardStack { } else this.currentSelectedSubcategory = ctx.data.customId; - // Reset page index so the new stack starts on page 0 - this.index = 0; + let resolveTime = Date.now(); try{ // If we have a cached result, retrieve it @@ -472,28 +514,82 @@ class DynamicCardStack { } // Compute the active cardstack. - this.activeCardStack = await this.interactive_components[ctx.data.customId].resolvePage(this); + let resolvedNewStack = await this.interactive_components[ctx.data.customId].resolvePage(this); - // TODO: this needs several modes/callback types. - // SUBSTACK - Creates a "submenu" with a brand new cardstack - // REPLACE_PARENT - Replaces the parent card in the root stack - // REPLACE_ROOT_STACK - Replaces the root stack + if(!Object.values(RESOLVE_CALLBACK_TYPES).includes(resolvedNewStack.type)) + throw new Error(`Invalid Stack Resolve Type (${resolvedNewStack.type})`); - // 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 - if(!this.interactive_components[ctx.data.customId].disableCache){ - this._setCachedValue(this.rootIndex, ctx.data.customId, STACK_CACHE_KEYS.RESULT_CARDS, [...this.activeCardStack]); + switch(resolvedNewStack.type){ + /** + * SUBSTACK + * + * Replace the currently active paging + * with a new, separate card stack to + * page through. + */ + case RESOLVE_CALLBACK_TYPES.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 RESOLVE_CALLBACK_TYPES.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 RESOLVE_CALLBACK_TYPES.REPLACE_STACK: + this.activeCardStack = resolvedNewStack.cards; + this.updatePageState(); + this.index = resolvedNewStack.index || this.rootIndex; + this.currentSelectedSubcategory = null; + break; } + } } catch(e){ - this.activeCardStack = [ - page(createEmbed("error", ctx, "Stack rendering failed.")) - ] + // 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) - // TODO: better errors maybe? } // Update the card stack with a card from the new stack. @@ -503,9 +599,20 @@ class DynamicCardStack { data: Object.assign(this.getCurrentCard(), { components: this._renderComponents()}) }) } else { - setTimeout(()=>{ - return this._edit(Object.assign(this.getCurrentCard(), {components:this._renderComponents()})) - }, 1500) + // 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; diff --git a/labscore/cardstack/index.js b/labscore/cardstack/index.js index 132d298..6660243 100644 --- a/labscore/cardstack/index.js +++ b/labscore/cardstack/index.js @@ -3,5 +3,6 @@ const { DynamicCardStack } = require("./DynamicCardStack"); module.exports = { createDynamicCardStack: (context, options)=>{ return new DynamicCardStack(context, options) - } + }, + CARD_STACK_CONSTANTS: require("./constants"), } \ No newline at end of file diff --git a/labscore/utils/embed.js b/labscore/utils/embed.js index 76c1148..ca7f305 100644 --- a/labscore/utils/embed.js +++ b/labscore/utils/embed.js @@ -148,6 +148,12 @@ module.exports.createEmbed = function(type, context, content){ } // Adds formatted page numbers to the embed footer +/** + * Formats embeds for pagination. + * @deprecated No longer necessary in DynamicCardStack. + * @param embeds Array of Messages + * @returns {Embed[]} + */ module.exports.formatPaginationEmbeds = function(embeds){ // No formatting if we only have one page if(embeds.length == 1) return embeds;