From 043c65a36c9d1fb59743d76c488df03583efcfd7 Mon Sep 17 00:00:00 2001 From: bignutty <3515180-bignutty@users.noreply.gitlab.com> Date: Tue, 11 Feb 2025 20:45:41 +0100 Subject: [PATCH 1/9] [nextgen] initial cardstack work --- commands/message/dev/test.js | 57 ++++- labscore/cardstack/DynamicCardStack.js | 309 +++++++++++++++++++++++++ labscore/utils/embed.js | 5 +- labscore/utils/markdown.js | 7 +- 4 files changed, 367 insertions(+), 11 deletions(-) create mode 100644 labscore/cardstack/DynamicCardStack.js diff --git a/commands/message/dev/test.js b/commands/message/dev/test.js index 293bc04..05b0bd9 100644 --- a/commands/message/dev/test.js +++ b/commands/message/dev/test.js @@ -1,7 +1,7 @@ -const { prideborder } = require("#api"); +const { createEmbed, page } = require("#utils/embed"); const { acknowledge } = require("#utils/interactions"); -const { editOrReply } = require("#utils/message"); +const DynamicCardStack = require("../../../labscore/cardstack/DynamicCardStack"); module.exports = { label: "text", @@ -17,8 +17,55 @@ module.exports = { onCancel: ()=>{}, run: async (context, args) => { await acknowledge(context); - - const a = await prideborder(context, "https://cdn.discordapp.com/emojis/1145727546747535412.png?size=4096") - editOrReply(context, "ok"); + + try{ + // This will create a new dynamic card stack + new DynamicCardStack(context, { + cards: [ + 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}` })), + interactive: { + conditional_button: { + // Button Label + label: "Conditional", + // Next to pagination or new row + inline: false, + visible: (page) => { + console.log(page.getAllState()); + console.log(page.getState("t_1")); + return (page.getState("key") === "t_1") + }, + resolvePage: (page) => { + return [ + createEmbed("default", context, { description: "this is a conditional sub page"}) + ].map((p)=>page(p)); + } + }, + dynamic_button: { + // Button Label + label: (page) => { + return page.getState("key"); + }, + // Next to pagination or new row + inline: false, + visible: true, + // Renders the loading state card + renderLoadingState: (page) => { + return createEmbed("default", context, { + description: "-# Subpage Loading :)", + }) + }, + resolvePage: (page) => { + return [ + createEmbed("default", context, { description: "this is a conditional sub page"}) + ].map((p)=>page(p)); + } + } + } + }) + }catch(e){ + console.log(e) + } } }; \ No newline at end of file diff --git a/labscore/cardstack/DynamicCardStack.js b/labscore/cardstack/DynamicCardStack.js new file mode 100644 index 0000000..fccdd58 --- /dev/null +++ b/labscore/cardstack/DynamicCardStack.js @@ -0,0 +1,309 @@ +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.loopPages = options.loop || true; + + this.pageState = []; + this.subcategoryState = SUBCATEGORY_STATE_TYPES.SINGLE_PAGE; + + 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 = Object.assign([], 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 + const listener = new Components({ + timeout: this.expires, + run: this._handleInteraction.bind(this), + }) + + //listener.components = this._renderComponents(); + + this._renderInitialComponents(listener) + + return this._edit({ + ...this.getCurrentCard(), + components: 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; + } + + 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; + } + 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 + * @param {boolean} customComponents Don't use - Meant for _spawn() + */ + async _edit(cardContent, customComponents = false){ + let message = Object.assign({}, cardContent); + + if(!customComponents) message.components = this._renderComponents(); + + 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.getPageIndex()]) return null; + return this.pageState[this.getPageIndex()][key]; + } + + getAllState(){ + return this.pageState; + } + + /** + * Component Helper + * @param {Components} listener + */ + _renderInitialComponents(listener){ + // First Row always starts with built-in components + listener.components = this._renderComponents(); + + console.log(listener) + console.log(JSON.stringify(listener)) + } + + /** + * Renders components and button states + */ + _renderComponents(){ + 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.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 + } + + 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) this.currentSelectedSubcategory = null; + else this.currentSelectedSubcategory = ctx.data.customId; + + 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)); + return ctx.respond({ + type: InteractionCallbackTypes.UPDATE_MESSAGE, + data: Object.assign(processingEmbed, { components: this._renderComponents()}) + }) + } + + console.error("Unknown button was triggered on stack: " + ctx.data.customId); + } +} + +module.exports = DynamicCardStack \ No newline at end of file diff --git a/labscore/utils/embed.js b/labscore/utils/embed.js index 57edf4a..e5423d5 100644 --- a/labscore/utils/embed.js +++ b/labscore/utils/embed.js @@ -180,9 +180,10 @@ module.exports.formatPaginationEmbeds = function(embeds){ } // Creates a page for our paginator. simple helper so we dont have to do {embeds:[]} every time -module.exports.page = function(embed, message = {}){ +module.exports.page = function(embed, message = {}, metadata = {}){ return Object.assign(message, { - embeds: [embed] + embeds: [embed], + _meta: metadata, }) } diff --git a/labscore/utils/markdown.js b/labscore/utils/markdown.js index de9a2df..fa7422e 100644 --- a/labscore/utils/markdown.js +++ b/labscore/utils/markdown.js @@ -33,11 +33,10 @@ module.exports.iconAsEmojiObject = function(icon){ let i = _icon(icon); return { - id: i.replace(/<:[a-z0-9_]*:([0-9]*)>/g,"$1"), - name: i, - animated: false, // TODO: fix this for animated emoji if we ever need them + id: i.replace(//g,"$1"), + name: "i", + animated: i.startsWith(" Date: Tue, 11 Feb 2025 21:19:57 +0100 Subject: [PATCH 2/9] [nextgen] working prototype --- commands/message/dev/test.js | 23 +++++-- labscore/cardstack/DynamicCardStack.js | 86 +++++++++++++++++--------- 2 files changed, 76 insertions(+), 33 deletions(-) diff --git a/commands/message/dev/test.js b/commands/message/dev/test.js index 05b0bd9..03cc7f7 100644 --- a/commands/message/dev/test.js +++ b/commands/message/dev/test.js @@ -26,6 +26,16 @@ module.exports = { createEmbed("default", context, { description: "page 2. this has a conditional button."}) ].map((p, index)=>page(p, {}, { key: `t_${index}` })), interactive: { + always_active_button: { + label: "single sub page", + inline: true, + visible: true, + resolvePage: ()=>{ + return [ + createEmbed("success", context, "smiley") + ].map((p)=>page(p)) + } + }, conditional_button: { // Button Label label: "Conditional", @@ -36,9 +46,10 @@ module.exports = { console.log(page.getState("t_1")); return (page.getState("key") === "t_1") }, - resolvePage: (page) => { + resolvePage: (pg) => { return [ - createEmbed("default", context, { description: "this is a conditional sub page"}) + 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)); } }, @@ -56,9 +67,13 @@ module.exports = { description: "-# Subpage Loading :)", }) }, - resolvePage: (page) => { + resolvePage: async (pg) => { + console.log("resolving page") return [ - createEmbed("default", context, { description: "this is a conditional sub page"}) + 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)); } } diff --git a/labscore/cardstack/DynamicCardStack.js b/labscore/cardstack/DynamicCardStack.js index fccdd58..0b5d218 100644 --- a/labscore/cardstack/DynamicCardStack.js +++ b/labscore/cardstack/DynamicCardStack.js @@ -42,10 +42,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.pageState = []; this.subcategoryState = SUBCATEGORY_STATE_TYPES.SINGLE_PAGE; + this.currentSelectedSubcategory = null; console.log("now spawning") this._spawn(); @@ -86,7 +88,7 @@ class DynamicCardStack { try{ this._createDynamicCardStack(this.context.client); - this.activeCardStack = Object.assign([], this.cards); + this.activeCardStack = [...this.cards]; // Resolve page state before let i = 0; @@ -99,18 +101,18 @@ class DynamicCardStack { } // Create internal component listener - const listener = new Components({ + this.listener = new Components({ timeout: this.expires, run: this._handleInteraction.bind(this), + onError: (e)=>{ + console.log(e) + } }) - //listener.components = this._renderComponents(); - - this._renderInitialComponents(listener) return this._edit({ ...this.getCurrentCard(), - components: listener + components: this.listener }, true); }catch(e){ console.log(e) @@ -127,6 +129,7 @@ class DynamicCardStack { 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() }); } @@ -137,6 +140,8 @@ class DynamicCardStack { 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() }); } @@ -148,12 +153,12 @@ class DynamicCardStack { * Edits the cardstack message. * Automatically applies and rerenders components. * @param {Message} cardContent Card Content - * @param {boolean} customComponents Don't use - Meant for _spawn() */ - async _edit(cardContent, customComponents = false){ + async _edit(cardContent){ let message = Object.assign({}, cardContent); - if(!customComponents) message.components = this._renderComponents(); + this.listener.components = this._renderComponents(); + message.components = this.listener; if(message["_meta"]) delete message["_meta"]; @@ -187,40 +192,30 @@ class DynamicCardStack { * @param {String} key */ getState(key){ - if(!this.pageState[this.getPageIndex()]) return null; - return this.pageState[this.getPageIndex()][key]; + if(!this.pageState[this.rootIndex]) return null; + return this.pageState[this.rootIndex][key]; } getAllState(){ return this.pageState; } - /** - * Component Helper - * @param {Components} listener - */ - _renderInitialComponents(listener){ - // First Row always starts with built-in components - listener.components = this._renderComponents(); - - console.log(listener) - console.log(JSON.stringify(listener)) - } - /** * Renders components and button states */ - _renderComponents(){ + _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.subcategoryState !== SUBCATEGORY_STATE_TYPES.SINGLE_PAGE), + disabled: this.activeCardStack.length === 1 || disabled || (this.subcategoryState !== SUBCATEGORY_STATE_TYPES.SINGLE_PAGE), emoji: iconAsEmojiObject(DEFAULT_BUTTON_ICON_MAPPINGS[b]) } @@ -238,7 +233,8 @@ class DynamicCardStack { let component = { type: MessageComponentTypes.BUTTON, customId: b, - style: button.style || 2 + style: button.style || 2, + disabled: disabled } if(button.label){ @@ -288,18 +284,50 @@ class DynamicCardStack { // interactive buttons if(this.interactive_components[ctx.data.customId]){ - if(this.currentSelectedSubcategory === ctx.data.customId) this.currentSelectedSubcategory = null; + 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)); - return ctx.respond({ + await ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, - data: Object.assign(processingEmbed, { components: this._renderComponents()}) + 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); From 4e62cecf0fa4c0d60ee1e9e3bed5d8bd0967f37c Mon Sep 17 00:00:00 2001 From: bignutty <3515180-bignutty@users.noreply.gitlab.com> Date: Tue, 11 Feb 2025 22:05:38 +0100 Subject: [PATCH 3/9] [nextgen] implement timeout --- commands/message/search/anime.js | 36 +++++++++++++--- labscore/cardstack/DynamicCardStack.js | 59 ++++++++++++++++---------- 2 files changed, 65 insertions(+), 30 deletions(-) diff --git a/commands/message/search/anime.js b/commands/message/search/anime.js index f8e8b4a..c87d8f0 100644 --- a/commands/message/search/anime.js +++ b/commands/message/search/anime.js @@ -7,6 +7,8 @@ const { acknowledge } = require('#utils/interactions'); const { smallPill, link, pill, stringwrap, stringwrapPreserveWords} = require('#utils/markdown'); const { editOrReply } = require('#utils/message'); +const DynamicCardStack = require("../../../labscore/cardstack/DynamicCardStack"); + function renderAnimeResultsPage(context, res){ let result = createEmbed("default", context, { author: { @@ -57,7 +59,8 @@ function renderAnimeResultsPage(context, res){ } return page(result, {}, { - episodes_key: res.supplemental.episodes + episodes_key: res.supplemental.episodes, + name: res.title }); } @@ -94,9 +97,8 @@ module.exports = { if(!pages.length) return editOrReply(context, createEmbed("warning", context, `No results found.`)) - await paginator.createPaginator({ - context, - pages: formatPaginationEmbeds(pages), + new DynamicCardStack(context, { + cards: formatPaginationEmbeds(pages), interactive: { episodes_button: { // Button Label @@ -107,16 +109,36 @@ module.exports = { condition: (page)=>{ return (page.getState("episodes_key") !== null) }, + renderLoadingState: (pg) => { + return createEmbed("default", context, { + description: `-# ${pg.getState("name")} > **Episodes**`, + image: { + url: `https://bignutty.gitlab.io/webstorage4/v2/assets/loading/04_chat_loading.1zn1ocfb72tc.gif` + } + }) + }, // Will resolve all conditions at paginator creation time precompute_conditions: true, - resolvePage: (page)=>{ + resolvePage: (pg)=>{ // If an interactive button for this index hasn't been // resolved yet, run this code - page.getState("episodes_key"); // -> supplemental key + pg.getState("episodes_key"); // -> supplemental key /* Resolve supplemental key via api */ + console.log("get episodes for " + pg.getState("episodes_key")) - return [...cards]; + let i = 0; + return [ + createEmbed("default", context, { + description: `-# ${pg.getState("name")} > **Episodes**\n\nepisode page ${i++}`, + }), + createEmbed("default", context, { + description: `-# ${pg.getState("name")} > **Episodes**\n\nepisode page ${i++}`, + }), + createEmbed("default", context, { + description: `-# ${pg.getState("name")} > **Episodes**\n\nepisode page ${i++}`, + }) + ].map((p)=>page(p)); } }, characters_button: { diff --git a/labscore/cardstack/DynamicCardStack.js b/labscore/cardstack/DynamicCardStack.js index 0b5d218..771d2f1 100644 --- a/labscore/cardstack/DynamicCardStack.js +++ b/labscore/cardstack/DynamicCardStack.js @@ -34,6 +34,7 @@ class DynamicCardStack { * @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 */ constructor(context, options){ this.context = context; @@ -44,22 +45,28 @@ class DynamicCardStack { this.index = options.startingIndex || 0; this.rootIndex = this.index; this.loopPages = options.loop || true; + this.expires = options.expires || 1*60*1000; + + this.uniqueId = (Date.now()*Math.random()).toString(36); this.pageState = []; this.subcategoryState = SUBCATEGORY_STATE_TYPES.SINGLE_PAGE; this.currentSelectedSubcategory = null; - console.log("now spawning") + console.log("now spawning " + this.uniqueId) this._spawn(); } /** * Kills the dynamic card stack. */ - kill(){ + async kill(clearComponents){ + console.log("killing " + this.uniqueId) clearTimeout(this.timeout); - // Remove reference to free the paginator for GC + if(clearComponents) await this._edit(this.currentPage(), []) + + // Remove reference to free the cardstack for GC activeStacks.delete(this.context.message?.id); } @@ -73,8 +80,9 @@ class DynamicCardStack { } _createDynamicCardStack(){ - // Kill any previously active paginators + // Kill any previously active cardstacks if(activeStacks.get(this.context.message?.id)){ + console.log(this.uniqueId + " is replacing " + this._getStackByMessageId(this.context.message?.id).uniqueId); this._getStackByMessageId(this.context.message?.id).kill(); } @@ -82,7 +90,7 @@ class DynamicCardStack { } /** - * Creates a new paginator in the given channel + * Creates a new cardstack in the given channel */ _spawn(){ try{ @@ -94,7 +102,6 @@ class DynamicCardStack { let i = 0; for(const ac of this.cards){ if(ac["_meta"]){ - console.log(ac) this.pageState[i] = Object.assign({}, ac["_meta"]); } i++; @@ -109,11 +116,15 @@ class DynamicCardStack { } }) + this.timeout = setTimeout(()=>{ + console.log(this.uniqueId + " timed out.") + this.kill(true); + }, this.expires) return this._edit({ ...this.getCurrentCard(), components: this.listener - }, true); + }); }catch(e){ console.log(e) } @@ -153,17 +164,22 @@ class DynamicCardStack { * Edits the cardstack message. * Automatically applies and rerenders components. * @param {Message} cardContent Card Content + * @param {boolean, Array} components Custom Components Array */ - async _edit(cardContent){ + async _edit(cardContent, components = false){ let message = Object.assign({}, cardContent); - this.listener.components = this._renderComponents(); - message.components = this.listener; + if(!components){ + this.listener.components = this._renderComponents(); + message.components = this.listener; + } else { + message.components = components; + } if(message["_meta"]) delete message["_meta"]; - console.log("GOING OUT:") - console.log(JSON.stringify(message, null, 2)) + //console.log("GOING OUT:") + //console.log(JSON.stringify(message, null, 2)) try{ return editOrReply(this.context, { @@ -177,7 +193,6 @@ class DynamicCardStack { } getCurrentCard(){ - console.log(this.activeCardStack[this.index]) return this.activeCardStack[this.index]; } @@ -193,6 +208,7 @@ class DynamicCardStack { */ getState(key){ if(!this.pageState[this.rootIndex]) return null; + if(!this.pageState[this.rootIndex][key]) return null; return this.pageState[this.rootIndex][key]; } @@ -209,8 +225,6 @@ class DynamicCardStack { // 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, @@ -237,6 +251,8 @@ class DynamicCardStack { disabled: disabled } + if(button.condition && typeof(button.condition) == "function") component.disabled = !button.condition(this); + if(button.label){ if(typeof(button.label) === "function") component.label = button.label(this); else component.label = button.label; @@ -250,7 +266,6 @@ class DynamicCardStack { else nComponentsSecondary.addButton(component); } - console.log(JSON.stringify(nComponents)) if(nComponentsSecondary.components.length >= 1) return [nComponents, nComponentsSecondary] return [nComponents]; } @@ -260,7 +275,6 @@ class DynamicCardStack { * @param {ComponentContext} ctx */ async _handleInteraction(ctx){ - console.log(ctx.data.customId) // should be a built-in button if(["next","previous"].includes(ctx.data.customId)){ @@ -309,22 +323,21 @@ class DynamicCardStack { data: Object.assign(processingEmbed, { components: this._renderComponents(true)}) }) - console.log("resolve trigger") try{ + // TODO: cache resolved stacks this.activeCardStack = await this.interactive_components[ctx.data.customId].resolvePage(this); } catch(e){ + this.activeCardStack = [ + page(createEmbed("error", ctx, "Stack rendering failed.")) + ] 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) + return this._edit(Object.assign(this.currentPage(), {components:this.listener})) }, 1500) return; From f226d7170abbf2fc6662fe7076b9f45856bd726e Mon Sep 17 00:00:00 2001 From: bignutty <3515180-bignutty@users.noreply.gitlab.com> Date: Tue, 11 Feb 2025 23:50:53 +0100 Subject: [PATCH 4/9] [nextgen] mostly implemented --- commands/interaction/slash/search/anime.js | 5 +- commands/interaction/slash/search/maps.js | 5 +- commands/interaction/slash/search/movie.js | 5 +- commands/message/dev/test.js | 53 +++++++++- commands/message/search/anime.js | 27 +++-- commands/message/search/maps.js | 5 +- commands/message/search/movie.js | 5 +- labscore/cardstack/DynamicCardStack.js | 112 ++++++++++++++++----- labscore/constants.js | 11 ++ labscore/utils/color.js | 7 ++ labscore/utils/embed.js | 4 - labscore/utils/statics.js | 5 + 12 files changed, 194 insertions(+), 50 deletions(-) create mode 100644 labscore/utils/color.js diff --git a/commands/interaction/slash/search/anime.js b/commands/interaction/slash/search/anime.js index a7486a6..0f1f59a 100644 --- a/commands/interaction/slash/search/anime.js +++ b/commands/interaction/slash/search/anime.js @@ -2,7 +2,8 @@ const { anime } = require('#api'); const { paginator } = require('#client'); const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES } = require('#constants'); -const { createEmbed, formatPaginationEmbeds, page, hexToEmbedColor } = require('#utils/embed'); +const { hexToDecimalColor } = require("#utils/color"); +const { createEmbed, formatPaginationEmbeds, page } = require('#utils/embed'); const { acknowledge } = require('#utils/interactions'); const { smallPill, link, pill, stringwrapPreserveWords} = require('#utils/markdown'); const { editOrReply } = require('#utils/message'); @@ -38,7 +39,7 @@ function renderAnimeResultsPage(context, res){ if(res.image) result.image = { url: res.image }; // Render Color - if(res.color) result.color = hexToEmbedColor(res.color); + if(res.color) result.color = hexToDecimalColor(res.color); // Render Episode Metadata if(res.episodes) { diff --git a/commands/interaction/slash/search/maps.js b/commands/interaction/slash/search/maps.js index d76dd87..40bf0e0 100644 --- a/commands/interaction/slash/search/maps.js +++ b/commands/interaction/slash/search/maps.js @@ -1,7 +1,8 @@ const { maps, mapsSupplemental } = require('#api'); const { PERMISSION_GROUPS } = require('#constants'); -const { createEmbed, hexToEmbedColor } = require('#utils/embed'); +const { hexToDecimalColor } = require("#utils/color"); +const { createEmbed } = require('#utils/embed'); const { acknowledge } = require('#utils/interactions'); const { link, icon, iconAsEmojiObject, citation } = require('#utils/markdown'); const { editOrReply } = require('#utils/message') @@ -19,7 +20,7 @@ function renderPlaceCard(context, place) { }, description: `${place.address.full}`, url: place.url, - color: hexToEmbedColor(place.style.color) + color: hexToDecimalColor(place.style.color) })] if (place.display_type) { diff --git a/commands/interaction/slash/search/movie.js b/commands/interaction/slash/search/movie.js index d41038d..fb734fc 100644 --- a/commands/interaction/slash/search/movie.js +++ b/commands/interaction/slash/search/movie.js @@ -2,7 +2,8 @@ const { movie } = require('#api'); const { paginator } = require('#client'); const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES, OMNI_MOVIE_TYPES } = require('#constants'); -const { createEmbed, formatPaginationEmbeds, page, hexToEmbedColor } = require('#utils/embed'); +const { hexToDecimalColor } = require("#utils/color"); +const { createEmbed, formatPaginationEmbeds, page } = require('#utils/embed'); const { acknowledge } = require('#utils/interactions'); const { smallPill, pill } = require('#utils/markdown'); const { editOrReply } = require('#utils/message'); @@ -29,7 +30,7 @@ function renderMovieResultsPage(context, res){ if(res.image) result.image = { url: res.image }; // Render Color - if(res.color) result.color = hexToEmbedColor(res.color); + if(res.color) result.color = hexToDecimalColor(res.color); return page(result); } diff --git a/commands/message/dev/test.js b/commands/message/dev/test.js index 03cc7f7..b65ff4f 100644 --- a/commands/message/dev/test.js +++ b/commands/message/dev/test.js @@ -30,6 +30,7 @@ module.exports = { label: "single sub page", inline: true, visible: true, + disableCache: true, resolvePage: ()=>{ return [ createEmbed("success", context, "smiley") @@ -42,8 +43,6 @@ module.exports = { // Next to pagination or new row inline: false, visible: (page) => { - console.log(page.getAllState()); - console.log(page.getState("t_1")); return (page.getState("key") === "t_1") }, resolvePage: (pg) => { @@ -76,7 +75,55 @@ module.exports = { createEmbed("default", context, { description: "this is a dynamic sub page " + Math.random()}) ].map((p)=>page(p)); } - } + }, + 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 c87d8f0..441c4d6 100644 --- a/commands/message/search/anime.js +++ b/commands/message/search/anime.js @@ -1,13 +1,15 @@ const { anime } = require('#api'); const { paginator } = require('#client'); -const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES } = require('#constants'); +const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES, COLORS, COLORS_HEX} = require('#constants'); -const { createEmbed, formatPaginationEmbeds, page, hexToEmbedColor } = require('#utils/embed'); +const { hexToDecimalColor } = require("#utils/color"); +const { createEmbed, formatPaginationEmbeds, page } = require('#utils/embed'); const { acknowledge } = require('#utils/interactions'); const { smallPill, link, pill, stringwrap, stringwrapPreserveWords} = require('#utils/markdown'); const { editOrReply } = require('#utils/message'); const DynamicCardStack = require("../../../labscore/cardstack/DynamicCardStack"); +const {STATIC_ASSETS} = require("#utils/statics"); function renderAnimeResultsPage(context, res){ let result = createEmbed("default", context, { @@ -39,7 +41,7 @@ function renderAnimeResultsPage(context, res){ if(res.image) result.image = { url: res.image }; // Render Color - if(res.color) result.color = hexToEmbedColor(res.color); + if(res.color) result.color = hexToDecimalColor(res.color); // Render Episode Metadata if(res.episodes) { @@ -60,7 +62,8 @@ function renderAnimeResultsPage(context, res){ return page(result, {}, { episodes_key: res.supplemental.episodes, - name: res.title + name: res.title, + color: hexToDecimalColor(res.color || COLORS_HEX.embed) }); } @@ -111,10 +114,11 @@ module.exports = { }, renderLoadingState: (pg) => { return createEmbed("default", context, { - description: `-# ${pg.getState("name")} > **Episodes**`, + description: `-# ${pg.getState("name")} › **Episodes**`, image: { - url: `https://bignutty.gitlab.io/webstorage4/v2/assets/loading/04_chat_loading.1zn1ocfb72tc.gif` - } + url: STATIC_ASSETS.card_skeleton + }, + color: pg.getState("color") }) }, // Will resolve all conditions at paginator creation time @@ -130,13 +134,16 @@ module.exports = { let i = 0; return [ createEmbed("default", context, { - description: `-# ${pg.getState("name")} > **Episodes**\n\nepisode page ${i++}`, + description: `-# ${pg.getState("name")} › **Episodes**\n\nepisode page ${i++}`, + color: pg.getState("color") }), createEmbed("default", context, { - description: `-# ${pg.getState("name")} > **Episodes**\n\nepisode page ${i++}`, + description: `-# ${pg.getState("name")} › **Episodes**\n\nepisode page ${i++}`, + color: pg.getState("color") }), createEmbed("default", context, { - description: `-# ${pg.getState("name")} > **Episodes**\n\nepisode page ${i++}`, + description: `-# ${pg.getState("name")} › **Episodes**\n\nepisode page ${i++}`, + color: pg.getState("color") }) ].map((p)=>page(p)); } diff --git a/commands/message/search/maps.js b/commands/message/search/maps.js index fff926e..5c11a45 100644 --- a/commands/message/search/maps.js +++ b/commands/message/search/maps.js @@ -1,7 +1,8 @@ const { maps, mapsSupplemental } = require('#api'); const { PERMISSION_GROUPS } = require('#constants'); -const { createEmbed, hexToEmbedColor } = require('#utils/embed'); +const { hexToDecimalColor } = require("#utils/color"); +const { createEmbed } = require('#utils/embed'); const { acknowledge } = require('#utils/interactions'); const { link, icon, iconAsEmojiObject, citation, stringwrap } = require('#utils/markdown'); const { editOrReply } = require('#utils/message') @@ -18,7 +19,7 @@ function renderPlaceCard(context, place) { }, description: `${place.address.full}`, url: place.url, - color: hexToEmbedColor(place.style.color) + color: hexToDecimalColor(place.style.color) })] if (place.display_type) { diff --git a/commands/message/search/movie.js b/commands/message/search/movie.js index 47486c8..4cf92de 100644 --- a/commands/message/search/movie.js +++ b/commands/message/search/movie.js @@ -2,7 +2,8 @@ const { movie } = require('#api'); const { paginator } = require('#client'); const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES, OMNI_MOVIE_TYPES } = require('#constants'); -const { createEmbed, formatPaginationEmbeds, page, hexToEmbedColor } = require('#utils/embed'); +const { hexToDecimalColor } = require("#utils/color"); +const { createEmbed, formatPaginationEmbeds, page } = require('#utils/embed'); const { acknowledge } = require('#utils/interactions'); const { smallPill, pill } = require('#utils/markdown'); const { editOrReply } = require('#utils/message'); @@ -28,7 +29,7 @@ function renderMovieResultsPage(context, res){ if(res.image) result.image = { url: res.image }; // Render Color - if(res.color) result.color = hexToEmbedColor(res.color); + if(res.color) result.color = hexToDecimalColor(res.color); return page(result); } diff --git a/labscore/cardstack/DynamicCardStack.js b/labscore/cardstack/DynamicCardStack.js index 771d2f1..873ee01 100644 --- a/labscore/cardstack/DynamicCardStack.js +++ b/labscore/cardstack/DynamicCardStack.js @@ -5,6 +5,7 @@ const { MessageComponentTypes, InteractionCallbackTypes } = require("detritus-cl const { Message } = require("detritus-client/lib/structures"); const { ComponentContext, Components, ComponentActionRow} = require("detritus-client/lib/utils"); const {createEmbed, page} = require("#utils/embed"); +const {STATIC_ASSETS} = require("#utils/statics"); const activeStacks = new Map(); @@ -19,6 +20,10 @@ const SUBCATEGORY_STATE_TYPES = Object.freeze({ MULTI_PAGE: 2, }); +const STACK_CACHE_KEYS = Object.freeze({ + RESULT_CARDS: 0 +}) + /** * DynamicCardStack represents an interactive stacks * of cards (embeds) for the user to paginate through @@ -35,6 +40,7 @@ class DynamicCardStack { * @param {Number} options.startingIndex Starting card index * @param {boolean} options.loop Wrap paging * @param {number} options.expires CardStack timeout + * @param {boolean} options.disableStackCache Allows disabling the stack result cache, meaning that every trigger will reevaluate a stack */ constructor(context, options){ this.context = context; @@ -49,10 +55,14 @@ class DynamicCardStack { this.uniqueId = (Date.now()*Math.random()).toString(36); + this.stackCache = {}; this.pageState = []; this.subcategoryState = SUBCATEGORY_STATE_TYPES.SINGLE_PAGE; this.currentSelectedSubcategory = null; + this.lastInteraction = Date.now(); + this.spawned = 0; + console.log("now spawning " + this.uniqueId) this._spawn(); } @@ -64,6 +74,7 @@ class DynamicCardStack { console.log("killing " + this.uniqueId) clearTimeout(this.timeout); + this.listener.clear(); if(clearComponents) await this._edit(this.currentPage(), []) // Remove reference to free the cardstack for GC @@ -89,6 +100,23 @@ class DynamicCardStack { activeStacks.set(this.context.message?.id, this); } + _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 */ @@ -116,10 +144,9 @@ class DynamicCardStack { } }) - this.timeout = setTimeout(()=>{ - console.log(this.uniqueId + " timed out.") - this.kill(true); - }, this.expires) + this.timeout = this._createTimeout() + + this.spawned = Date.now() return this._edit({ ...this.getCurrentCard(), @@ -221,7 +248,7 @@ class DynamicCardStack { */ _renderComponents(disabled = false){ let nComponents = new ComponentActionRow({}) - let nComponentsSecondary = new ComponentActionRow({}) + let nComponentsSecondary = [new ComponentActionRow({})] // First Row always starts with built-in components for(const b of this.buttons){ @@ -251,7 +278,7 @@ class DynamicCardStack { disabled: disabled } - if(button.condition && typeof(button.condition) == "function") component.disabled = !button.condition(this); + if(!disabled && button.condition && typeof(button.condition) == "function") component.disabled = !button.condition(this); if(button.label){ if(typeof(button.label) === "function") component.label = button.label(this); @@ -263,19 +290,43 @@ class DynamicCardStack { if(this.currentSelectedSubcategory === b) component.style = 1; if(button.inline) nComponents.addButton(component); - else nComponentsSecondary.addButton(component); + else { + if(nComponentsSecondary[nComponentsSecondary.length - 1].components.length >= 5){ + nComponentsSecondary.push(new ComponentActionRow({})) + } + nComponentsSecondary[nComponentsSecondary.length - 1].addButton(component); + } } - if(nComponentsSecondary.components.length >= 1) return [nComponents, nComponentsSecondary] + if(nComponentsSecondary[0].components.length >= 1) return [nComponents, ...nComponentsSecondary] return [nComponents]; } + _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; + } + + _getCachedValue(index, componentId, key){ + 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(); + // should be a built-in button if(["next","previous"].includes(ctx.data.customId)){ console.log("triggering button") @@ -311,21 +362,38 @@ class DynamicCardStack { } else this.currentSelectedSubcategory = ctx.data.customId; - this.cachedIndex = this.index; + console.log("new category is " + this.currentSelectedSubcategory) - 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)}) - }) + // TODO: allow overriding index + this.index = 0; try{ - // TODO: cache resolved stacks - this.activeCardStack = await this.interactive_components[ctx.data.customId].resolvePage(this); + 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.currentPage(), {components:this._renderComponents(false)}) + }) + return; + } else { + let processingEmbed = page(createEmbed("default", ctx, { + image: { + url: STATIC_ASSETS.card_skeleton + } + })) + + 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)}) + }) + + this.activeCardStack = await this.interactive_components[ctx.data.customId].resolvePage(this); + + if(!this.interactive_components[ctx.data.customId].disableCache){ + this._setCachedValue(this.rootIndex, ctx.data.customId, STACK_CACHE_KEYS.RESULT_CARDS, [...this.activeCardStack]); + } + } } catch(e){ this.activeCardStack = [ page(createEmbed("error", ctx, "Stack rendering failed.")) @@ -333,11 +401,9 @@ class DynamicCardStack { console.log("resolve failed:") console.log(e) } - // TODO: allow overriding index - this.index = 0; setTimeout(()=>{ - return this._edit(Object.assign(this.currentPage(), {components:this.listener})) + return this._edit(Object.assign(this.currentPage(), {components:this._renderComponents()})) }, 1500) return; diff --git a/labscore/constants.js b/labscore/constants.js index 143de18..7e0245f 100644 --- a/labscore/constants.js +++ b/labscore/constants.js @@ -1,4 +1,5 @@ const { Permissions } = require("detritus-client/lib/constants") +const { decimalToHexColor } = require("#utils/color"); module.exports.DISCORD_INVITES = Object.freeze({ support: "https://discord.gg/8c4p6xcjru", @@ -40,6 +41,16 @@ module.exports.COLORS = Object.freeze({ incognito: 10057726, }) +module.exports.COLORS_HEX = Object.freeze({ + error: decimalToHexColor(module.exports.COLORS.error), + success: decimalToHexColor(module.exports.COLORS.success), + warning: decimalToHexColor(module.exports.COLORS.warning), + embed: decimalToHexColor(module.exports.COLORS.embed), + brand: decimalToHexColor(module.exports.COLORS.brand), + nsfw: decimalToHexColor(module.exports.COLORS.nsfw), + incognito: decimalToHexColor(module.exports.COLORS.incognito), +}) + // Permission requirements that apply to commands module.exports.PERMISSION_GROUPS = Object.freeze({ // Baseline permission set for regular commands diff --git a/labscore/utils/color.js b/labscore/utils/color.js new file mode 100644 index 0000000..ed77871 --- /dev/null +++ b/labscore/utils/color.js @@ -0,0 +1,7 @@ +module.exports.hexToDecimalColor = (color)=>{ + return parseInt(color.split("#")[1], 16) +} + +module.exports.decimalToHexColor = (color)=>{ + return "#" + color.toString(16); +} \ No newline at end of file diff --git a/labscore/utils/embed.js b/labscore/utils/embed.js index e5423d5..76c1148 100644 --- a/labscore/utils/embed.js +++ b/labscore/utils/embed.js @@ -185,8 +185,4 @@ module.exports.page = function(embed, message = {}, metadata = {}){ embeds: [embed], _meta: metadata, }) -} - -module.exports.hexToEmbedColor = (color)=>{ - return parseInt(color.split("#")[1], 16) } \ No newline at end of file diff --git a/labscore/utils/statics.js b/labscore/utils/statics.js index 8a976e7..f4a8a63 100644 --- a/labscore/utils/statics.js +++ b/labscore/utils/statics.js @@ -10,6 +10,10 @@ const Statics = Object.freeze({ } }, assets: { + card_skeleton: { + file: "loading/04_chat_loading.1zn1ocfb72tc.gif", + revision: 0 + }, chat_loading: { file: "loading/05_chat_loading.7y2ji893rho0.gif", revision: 0 @@ -291,6 +295,7 @@ module.exports.STATIC_ICONS = Object.freeze({ }) module.exports.STATIC_ASSETS = Object.freeze({ + card_skeleton: staticAsset(Statics.assets.card_skeleton), chat_loading: staticAsset(Statics.assets.chat_loading), chat_loading_small: staticAsset(Statics.assets.chat_loading_small), image_loading: staticAsset(Statics.assets.image_loading), From 01a10135d89b952989c5333f4151a6f009123d84 Mon Sep 17 00:00:00 2001 From: bignutty <3515180-bignutty@users.noreply.gitlab.com> Date: Wed, 12 Feb 2025 00:53:10 +0100 Subject: [PATCH 5/9] [nextgen] implement supplemental data retrieval --- commands/message/search/anime.js | 105 +++++++++++++++++++------------ labscore/utils/markdown.js | 14 ++++- 2 files changed, 78 insertions(+), 41 deletions(-) diff --git a/commands/message/search/anime.js b/commands/message/search/anime.js index 441c4d6..c87d75d 100644 --- a/commands/message/search/anime.js +++ b/commands/message/search/anime.js @@ -1,11 +1,10 @@ -const { anime } = require('#api'); -const { paginator } = require('#client'); -const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES, COLORS, COLORS_HEX} = require('#constants'); +const { anime, animeSupplemental} = require('#api'); +const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES, COLORS_HEX} = require('#constants'); const { hexToDecimalColor } = require("#utils/color"); const { createEmbed, formatPaginationEmbeds, page } = require('#utils/embed'); const { acknowledge } = require('#utils/interactions'); -const { smallPill, link, pill, stringwrap, stringwrapPreserveWords} = require('#utils/markdown'); +const { smallPill, link, pill, stringwrapPreserveWords, timestamp, TIMESTAMP_FLAGS} = require('#utils/markdown'); const { editOrReply } = require('#utils/message'); const DynamicCardStack = require("../../../labscore/cardstack/DynamicCardStack"); @@ -62,8 +61,10 @@ function renderAnimeResultsPage(context, res){ return page(result, {}, { episodes_key: res.supplemental.episodes, + characters_key: res.supplemental.characters, name: res.title, - color: hexToDecimalColor(res.color || COLORS_HEX.embed) + color: hexToDecimalColor(res.color || COLORS_HEX.embed), + cover: res.cover }); } @@ -91,7 +92,7 @@ module.exports = { let search = await anime(context, args.query, context.channel.nsfw) search = search.response - if(search.body.status == 2) return editOrReply(context, createEmbed("error", context, search.body.message)) + if(search.body.status === 2) return editOrReply(context, createEmbed("error", context, search.body.message)) let pages = [] for(const res of search.body.results){ @@ -109,7 +110,7 @@ module.exports = { // Next to pagination or new row inline: false, visible: true, - condition: (page)=>{ + condition: (page) => { return (page.getState("episodes_key") !== null) }, renderLoadingState: (pg) => { @@ -121,31 +122,35 @@ module.exports = { color: pg.getState("color") }) }, - // Will resolve all conditions at paginator creation time - precompute_conditions: true, - resolvePage: (pg)=>{ - // If an interactive button for this index hasn't been - // resolved yet, run this code - pg.getState("episodes_key"); // -> supplemental key + resolvePage: async (pg) => { + let episodes = await animeSupplemental(context, pg.getState("episodes_key")); - /* Resolve supplemental key via api */ - console.log("get episodes for " + pg.getState("episodes_key")) - - let i = 0; - return [ - createEmbed("default", context, { - description: `-# ${pg.getState("name")} › **Episodes**\n\nepisode page ${i++}`, - color: pg.getState("color") - }), - createEmbed("default", context, { - description: `-# ${pg.getState("name")} › **Episodes**\n\nepisode page ${i++}`, - color: pg.getState("color") - }), - createEmbed("default", context, { - description: `-# ${pg.getState("name")} › **Episodes**\n\nepisode page ${i++}`, - color: pg.getState("color") + let cards = episodes.response.body.episodes.map((e) => { + let card = createEmbed("default", context, { + color: pg.getState("color"), + description: `-# ${pg.getState("name")} › **Episodes**\n## `, + fields: [] }) - ].map((p)=>page(p)); + + // Render episode number if available + if (e.episode) card.description += `${e.episode}: ` + card.description += e.title; + + if (e.description) card.description += `\n\n\n${stringwrapPreserveWords(e.description, 600)}`; + if (e.image) card.image = {url: e.image}; + if (pg.getState("cover")) card.thumbnail = {url: pg.getState("cover")}; + + if (e.duration) card.fields.push({name: "Length", value: e.duration + " min", inline: true}) + if (e.date) card.fields.push({ + name: "Aired", + value: timestamp(e.date, TIMESTAMP_FLAGS.LONG_DATE), + inline: true + }) + + return page(card) + }) + + return formatPaginationEmbeds(cards); } }, characters_button: { @@ -153,24 +158,46 @@ module.exports = { label: "Characters", // Next to pagination or new row inline: false, - condition: (page)=>{ + visible: true, + condition: (page) => { return (page.getState("characters_key") !== null) }, - resolvePage: (page)=>{ - // If an interactive button for this index hasn't been - // resolved yet, run this code - page.getState("characters_key"); // -> supplemental key + renderLoadingState: (pg) => { + return createEmbed("default", context, { + description: `-# ${pg.getState("name")} › **Characters**`, + image: { + url: STATIC_ASSETS.card_skeleton + }, + color: pg.getState("color") + }) + }, + resolvePage: async (pg) => { + let characters = await animeSupplemental(context, pg.getState("characters_key")); - /* Resolve supplemental key via api */ + let cards = characters.response.body.characters.map((c) => { + let card = createEmbed("default", context, { + color: pg.getState("color"), + description: `-# ${pg.getState("name")} › **Characters**\n## ${link(c.url, c.name.full)}`, + fields: [] + }) - return [...cards]; + if (c.description) card.description += `\n\n\n${stringwrapPreserveWords(c.description, 600)}`; + if (c.image) card.image = {url: c.image}; + if (pg.getState("cover")) card.thumbnail = {url: pg.getState("cover")}; + + if (c.age) card.fields.push({name: "Age", value: c.age, inline: true}) + + return page(card) + }) + + return formatPaginationEmbeds(cards); } } } }); }catch(e){ - if(e.response?.body?.status == 1) return editOrReply(context, createEmbed("warning", context, e.response?.body?.message)) - if(e.response?.body?.status == 2) return editOrReply(context, createEmbed("warning", context, e.response?.body?.message)) + if(e.response?.body?.status === 1) return editOrReply(context, createEmbed("warning", context, e.response?.body?.message)) + if(e.response?.body?.status === 2) return editOrReply(context, createEmbed("warning", context, e.response?.body?.message)) console.log(e) return editOrReply(context, createEmbed("error", context, `Unable to perform anime search.`)) diff --git a/labscore/utils/markdown.js b/labscore/utils/markdown.js index fa7422e..28f7f0d 100644 --- a/labscore/utils/markdown.js +++ b/labscore/utils/markdown.js @@ -24,7 +24,7 @@ function _icon(icon){ // Ensures potentially user-provided content won't escape pill components function _escapeCodeblock(content){ - return content.toString().replace(/\`/g, 'ˋ'); + return content.toString().replace(/`/g, 'ˋ'); } module.exports.icon = _icon; @@ -55,7 +55,7 @@ module.exports.weatherIcon = function(icon){ } module.exports.highlight = function(content = ""){ - return "`" + content.toString().replace(/\`/g, 'ˋ') + "`" + return "`" + content.toString().replace(/`/g, 'ˋ') + "`" } /** @@ -76,6 +76,16 @@ module.exports.link = function(url, masked, tooltip = "", embed = false){ return url } +module.exports.TIMESTAMP_FLAGS = Object.freeze({ + SHORT_TIME: "t", + LONG_TIME: "T", + SHORT_DATE: "d", + LONG_DATE: "D", + SHORT_DATE_TIME: "f", + LONG_DATE_TIME: "F", + RELATIVE_TIME: "R" +}) + module.exports.timestamp = function(time, flag = "t"){ return `` } From fe8358ccf48fde29c9f6b96dce28cd84187d024a Mon Sep 17 00:00:00 2001 From: bignutty <3515180-bignutty@users.noreply.gitlab.com> Date: Wed, 12 Feb 2025 01:25:29 +0100 Subject: [PATCH 6/9] [nextgen] fix memory leak, change default expiry to 5 minutes --- commands/message/dev/test.js | 2 +- commands/message/search/anime.js | 2 +- labscore/cardstack/DynamicCardStack.js | 20 ++++++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/commands/message/dev/test.js b/commands/message/dev/test.js index b65ff4f..a1eac10 100644 --- a/commands/message/dev/test.js +++ b/commands/message/dev/test.js @@ -1,7 +1,7 @@ const { createEmbed, page } = require("#utils/embed"); const { acknowledge } = require("#utils/interactions"); -const DynamicCardStack = require("../../../labscore/cardstack/DynamicCardStack"); +const { DynamicCardStack } = require("../../../labscore/cardstack/DynamicCardStack"); module.exports = { label: "text", diff --git a/commands/message/search/anime.js b/commands/message/search/anime.js index c87d75d..bb2e698 100644 --- a/commands/message/search/anime.js +++ b/commands/message/search/anime.js @@ -7,7 +7,7 @@ const { acknowledge } = require('#utils/interactions'); const { smallPill, link, pill, stringwrapPreserveWords, timestamp, TIMESTAMP_FLAGS} = require('#utils/markdown'); const { editOrReply } = require('#utils/message'); -const DynamicCardStack = require("../../../labscore/cardstack/DynamicCardStack"); +const { DynamicCardStack } = require("../../../labscore/cardstack/DynamicCardStack"); const {STATIC_ASSETS} = require("#utils/statics"); function renderAnimeResultsPage(context, res){ diff --git a/labscore/cardstack/DynamicCardStack.js b/labscore/cardstack/DynamicCardStack.js index 873ee01..3b7d93f 100644 --- a/labscore/cardstack/DynamicCardStack.js +++ b/labscore/cardstack/DynamicCardStack.js @@ -1,13 +1,14 @@ +const { createEmbed, page } = require("#utils/embed"); const { iconAsEmojiObject } = 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 {createEmbed, page} = require("#utils/embed"); -const {STATIC_ASSETS} = require("#utils/statics"); -const activeStacks = new Map(); +const activeStacks = new WeakMap(); const DEFAULT_BUTTON_ICON_MAPPINGS = Object.freeze({ "next": "button_chevron_right", @@ -51,7 +52,7 @@ class DynamicCardStack { this.index = options.startingIndex || 0; this.rootIndex = this.index; this.loopPages = options.loop || true; - this.expires = options.expires || 1*60*1000; + this.expires = options.expires || 5*60*1000; this.uniqueId = (Date.now()*Math.random()).toString(36); @@ -78,7 +79,7 @@ class DynamicCardStack { if(clearComponents) await this._edit(this.currentPage(), []) // Remove reference to free the cardstack for GC - activeStacks.delete(this.context.message?.id); + activeStacks.delete(this.context.message); } /** @@ -92,12 +93,12 @@ class DynamicCardStack { _createDynamicCardStack(){ // Kill any previously active cardstacks - if(activeStacks.get(this.context.message?.id)){ + if(activeStacks.get(this.context.message)){ console.log(this.uniqueId + " is replacing " + this._getStackByMessageId(this.context.message?.id).uniqueId); - this._getStackByMessageId(this.context.message?.id).kill(); + this._getStackByMessageId(this.context.message).kill(); } - activeStacks.set(this.context.message?.id, this); + activeStacks.set(this.context.message, this); } _createTimeout(){ @@ -364,7 +365,6 @@ class DynamicCardStack { console.log("new category is " + this.currentSelectedSubcategory) - // TODO: allow overriding index this.index = 0; try{ @@ -413,4 +413,4 @@ class DynamicCardStack { } } -module.exports = DynamicCardStack \ No newline at end of file +module.exports.DynamicCardStack = DynamicCardStack; \ No newline at end of file From 510cbbe60dcba14e3ae34e4c06459fe90316d994 Mon Sep 17 00:00:00 2001 From: bignutty <3515180-bignutty@users.noreply.gitlab.com> Date: Wed, 12 Feb 2025 02:49:22 +0100 Subject: [PATCH 7/9] [cardstack] fix interaction contexts, cleanup code, add comments/documentation --- commands/interaction/slash/search/anime.js | 117 +++++++--- commands/message/search/anime.js | 16 +- labscore/cardstack/DynamicCardStack.js | 250 ++++++++++++++------- labscore/cardstack/index.js | 7 + package.json | 1 + 5 files changed, 273 insertions(+), 118 deletions(-) diff --git a/commands/interaction/slash/search/anime.js b/commands/interaction/slash/search/anime.js index 0f1f59a..1f9ca59 100644 --- a/commands/interaction/slash/search/anime.js +++ b/commands/interaction/slash/search/anime.js @@ -1,13 +1,15 @@ -const { anime } = require('#api'); -const { paginator } = require('#client'); -const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES } = require('#constants'); +const { anime, animeSupplemental} = require('#api'); +const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES, COLORS_HEX} = require('#constants'); + +const { createDynamicCardStack } = require("#cardstack"); const { hexToDecimalColor } = require("#utils/color"); const { createEmbed, formatPaginationEmbeds, page } = require('#utils/embed'); const { acknowledge } = require('#utils/interactions'); -const { smallPill, link, pill, stringwrapPreserveWords} = require('#utils/markdown'); +const { smallPill, link, pill, stringwrapPreserveWords, timestamp, TIMESTAMP_FLAGS} = require('#utils/markdown'); const { editOrReply } = require('#utils/message'); const { InteractionContextTypes, ApplicationIntegrationTypes, ApplicationCommandOptionTypes } = require('detritus-client/lib/constants'); +const { STATIC_ASSETS } = require("#utils/statics"); function renderAnimeResultsPage(context, res){ let result = createEmbed("default", context, { @@ -59,7 +61,13 @@ function renderAnimeResultsPage(context, res){ } return page(result, {}, { - episodes_key: res.supplemental.episodes + // Supplemental keys are provided by the backend, + // allow for fetching extra data related to results. + episodes_key: res.supplemental.episodes, + characters_key: res.supplemental.characters, + name: res.title, + color: hexToDecimalColor(res.color || COLORS_HEX.embed), + cover: res.cover }); } @@ -98,7 +106,7 @@ module.exports = { let search = await anime(context, args.query, context.channel.nsfw) search = search.response - if(search.body.status == 2) return editOrReply(context, createEmbed("error", context, search.body.message)) + if(search.body.status === 2) return editOrReply(context, createEmbed("error", context, search.body.message)) let pages = [] for(const res of search.body.results){ @@ -106,55 +114,100 @@ module.exports = { } if(!pages.length) return editOrReply(context, createEmbed("warning", context, `No results found.`)) - - await paginator.createPaginator({ - context, - pages: formatPaginationEmbeds(pages), + + createDynamicCardStack(context, { + cards: formatPaginationEmbeds(pages), interactive: { episodes_button: { - // Button Label label: "Episodes", - // Next to pagination or new row inline: false, visible: true, - condition: (page)=>{ + condition: (page) => { return (page.getState("episodes_key") !== null) }, - // Will resolve all conditions at paginator creation time - precompute_conditions: true, - resolvePage: (page)=>{ - // If an interactive button for this index hasn't been - // resolved yet, run this code - page.getState("episodes_key"); // -> supplemental key + renderLoadingState: (pg) => { + return createEmbed("default", context, { + description: `-# ${pg.getState("name")} › **Episodes**`, + image: { + url: STATIC_ASSETS.card_skeleton + }, + color: pg.getState("color") + }) + }, + resolvePage: async (pg) => { + let episodes = await animeSupplemental(context, pg.getState("episodes_key")); - /* Resolve supplemental key via api */ + let cards = episodes.response.body.episodes.map((e) => { + let card = createEmbed("default", context, { + color: pg.getState("color"), + description: `-# ${pg.getState("name")} › **Episodes**\n## `, + fields: [] + }) - return [...cards]; + // Render episode number if available + if (e.episode) card.description += `${e.episode}: ` + card.description += e.title; + + if (e.description) card.description += `\n\n\n${stringwrapPreserveWords(e.description, 600)}`; + if (e.image) card.image = {url: e.image}; + if (pg.getState("cover")) card.thumbnail = {url: pg.getState("cover")}; + + if (e.duration) card.fields.push({name: "Length", value: e.duration + " min", inline: true}) + if (e.date) card.fields.push({ + name: "Aired", + value: timestamp(e.date, TIMESTAMP_FLAGS.LONG_DATE), + inline: true + }) + + return page(card) + }) + + return formatPaginationEmbeds(cards); } }, characters_button: { - // Button Label label: "Characters", - // Next to pagination or new row inline: false, - condition: (page)=>{ + visible: true, + condition: (page) => { return (page.getState("characters_key") !== null) }, - resolvePage: (page)=>{ - // If an interactive button for this index hasn't been - // resolved yet, run this code - page.getState("characters_key"); // -> supplemental key + renderLoadingState: (pg) => { + return createEmbed("default", context, { + description: `-# ${pg.getState("name")} › **Characters**`, + image: { + url: STATIC_ASSETS.card_skeleton + }, + color: pg.getState("color") + }) + }, + resolvePage: async (pg) => { + let characters = await animeSupplemental(context, pg.getState("characters_key")); - /* Resolve supplemental key via api */ + let cards = characters.response.body.characters.map((c) => { + let card = createEmbed("default", context, { + color: pg.getState("color"), + description: `-# ${pg.getState("name")} › **Characters**\n## ${link(c.url, c.name.full)}`, + fields: [] + }) - return [...cards]; + if (c.description) card.description += `\n\n\n${stringwrapPreserveWords(c.description, 600)}`; + if (c.image) card.image = {url: c.image}; + if (pg.getState("cover")) card.thumbnail = {url: pg.getState("cover")}; + + if (c.age) card.fields.push({name: "Age", value: c.age, inline: true}) + + return page(card) + }) + + return formatPaginationEmbeds(cards); } } } }); }catch(e){ - if(e.response?.body?.status == 1) return editOrReply(context, createEmbed("warning", context, e.response?.body?.message)) - if(e.response?.body?.status == 2) return editOrReply(context, createEmbed("warning", context, e.response?.body?.message)) + if(e.response?.body?.status === 1) return editOrReply(context, createEmbed("warning", context, e.response?.body?.message)) + if(e.response?.body?.status === 2) return editOrReply(context, createEmbed("warning", context, e.response?.body?.message)) console.log(e) return editOrReply(context, createEmbed("error", context, `Unable to perform anime search.`)) diff --git a/commands/message/search/anime.js b/commands/message/search/anime.js index bb2e698..84ec389 100644 --- a/commands/message/search/anime.js +++ b/commands/message/search/anime.js @@ -1,14 +1,14 @@ const { anime, animeSupplemental} = require('#api'); const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES, COLORS_HEX} = require('#constants'); +const { createDynamicCardStack } = require("#cardstack"); + const { hexToDecimalColor } = require("#utils/color"); const { createEmbed, formatPaginationEmbeds, 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'); - -const { DynamicCardStack } = require("../../../labscore/cardstack/DynamicCardStack"); -const {STATIC_ASSETS} = require("#utils/statics"); +const { STATIC_ASSETS } = require("#utils/statics"); function renderAnimeResultsPage(context, res){ let result = createEmbed("default", context, { @@ -60,6 +60,8 @@ function renderAnimeResultsPage(context, res){ } return page(result, {}, { + // Supplemental keys are provided by the backend, + // allow for fetching extra data related to results. episodes_key: res.supplemental.episodes, characters_key: res.supplemental.characters, name: res.title, @@ -101,13 +103,11 @@ module.exports = { if(!pages.length) return editOrReply(context, createEmbed("warning", context, `No results found.`)) - new DynamicCardStack(context, { + createDynamicCardStack(context, { cards: formatPaginationEmbeds(pages), interactive: { episodes_button: { - // Button Label label: "Episodes", - // Next to pagination or new row inline: false, visible: true, condition: (page) => { @@ -154,9 +154,7 @@ module.exports = { } }, characters_button: { - // Button Label label: "Characters", - // Next to pagination or new row inline: false, visible: true, condition: (page) => { @@ -202,5 +200,5 @@ module.exports = { console.log(e) return editOrReply(context, createEmbed("error", context, `Unable to perform anime search.`)) } - }, + } }; \ No newline at end of file diff --git a/labscore/cardstack/DynamicCardStack.js b/labscore/cardstack/DynamicCardStack.js index 3b7d93f..601d6bb 100644 --- a/labscore/cardstack/DynamicCardStack.js +++ b/labscore/cardstack/DynamicCardStack.js @@ -8,6 +8,10 @@ const { MessageComponentTypes, InteractionCallbackTypes } = require("detritus-cl const { Message } = require("detritus-client/lib/structures"); const { ComponentContext, Components, ComponentActionRow} = require("detritus-client/lib/utils"); +/** + * Stores all active card stacks + * @type {WeakMap} + */ const activeStacks = new WeakMap(); const DEFAULT_BUTTON_ICON_MAPPINGS = Object.freeze({ @@ -15,12 +19,6 @@ const DEFAULT_BUTTON_ICON_MAPPINGS = Object.freeze({ "previous": "button_chevron_left" }); -const SUBCATEGORY_STATE_TYPES = Object.freeze({ - NONE: 0, - SINGLE_PAGE: 1, - MULTI_PAGE: 2, -}); - const STACK_CACHE_KEYS = Object.freeze({ RESULT_CARDS: 0 }) @@ -58,13 +56,13 @@ class DynamicCardStack { this.stackCache = {}; this.pageState = []; - this.subcategoryState = SUBCATEGORY_STATE_TYPES.SINGLE_PAGE; this.currentSelectedSubcategory = null; + /* this.lastInteraction = Date.now(); this.spawned = 0; + */ - console.log("now spawning " + this.uniqueId) this._spawn(); } @@ -76,31 +74,41 @@ class DynamicCardStack { clearTimeout(this.timeout); this.listener.clear(); - if(clearComponents) await this._edit(this.currentPage(), []) + if(clearComponents) await this._edit(this.getCurrentCard(), []) // Remove reference to free the cardstack for GC - activeStacks.delete(this.context.message); + activeStacks.delete(this.context.message || this.context.interaction); } /** - * Get a Stack based on its attached message ID - * @param {*} id Attached message ID + * Get a Stack from an attached reference (message/interaction). + * @param {Message} ref Attached message/interaction * @returns {DynamicCardStack} */ - _getStackByMessageId(id){ - return activeStacks.get(id); + _getStackByReference(ref){ + return activeStacks.get(ref); } + /** + * Attaches a cardstack to its internal reference. + * @private + */ _createDynamicCardStack(){ - // Kill any previously active cardstacks - if(activeStacks.get(this.context.message)){ - console.log(this.uniqueId + " is replacing " + this._getStackByMessageId(this.context.message?.id).uniqueId); - this._getStackByMessageId(this.context.message).kill(); + // 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); + 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(()=>{ /* @@ -127,7 +135,8 @@ class DynamicCardStack { this.activeCardStack = [...this.cards]; - // Resolve page state before + // Resolve page state for all + // root pages. let i = 0; for(const ac of this.cards){ if(ac["_meta"]){ @@ -147,7 +156,7 @@ class DynamicCardStack { this.timeout = this._createTimeout() - this.spawned = Date.now() + //this.spawned = Date.now() return this._edit({ ...this.getCurrentCard(), @@ -158,21 +167,34 @@ class DynamicCardStack { } } - getPageByIndex(index){ + /** + * Gets a card from the currently active + * stack by its index + * @param index Page Index + * @returns {*} + */ + getCardByIndex(index){ return this.activeCardStack[index]; } - nextPage(){ + /** + * 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; - console.log("new index: " + this.index) - return Object.assign(this.getPageByIndex(this.index), { components: this._renderComponents() }); + 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){ @@ -181,11 +203,7 @@ class DynamicCardStack { } 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() }); + return Object.assign(this.getCardByIndex(this.index), { components: this._renderComponents() }); } /** @@ -206,9 +224,6 @@ class DynamicCardStack { if(message["_meta"]) delete message["_meta"]; - //console.log("GOING OUT:") - //console.log(JSON.stringify(message, null, 2)) - try{ return editOrReply(this.context, { ...message, @@ -220,18 +235,17 @@ class DynamicCardStack { } } - getCurrentCard(){ - return this.activeCardStack[this.index]; - } - - getPageIndex(){ - return this.index; - } - - // API for contextual buttons - /** - * Retrieves state from the currently active page + * 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){ @@ -240,6 +254,11 @@ class DynamicCardStack { return this.pageState[this.rootIndex][key]; } + /** + * Returns all page state. + * Only really intended for debugging purposes. + * @returns {Object} + */ getAllState(){ return this.pageState; } @@ -257,7 +276,7 @@ class DynamicCardStack { type: MessageComponentTypes.BUTTON, customId: b, style: 2, - disabled: this.activeCardStack.length === 1 || disabled || (this.subcategoryState !== SUBCATEGORY_STATE_TYPES.SINGLE_PAGE), + disabled: this.activeCardStack.length === 1 || disabled, emoji: iconAsEmojiObject(DEFAULT_BUTTON_ICON_MAPPINGS[b]) } @@ -279,22 +298,40 @@ class DynamicCardStack { disabled: disabled } - if(!disabled && button.condition && typeof(button.condition) == "function") component.disabled = !button.condition(this); + // 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 - // Display the selected button + + // Change color if this is the active button. + // TODO: allow overwriting the "active" color if(this.currentSelectedSubcategory === b) component.style = 1; - if(button.inline) nComponents.addButton(component); - else { - if(nComponentsSecondary[nComponentsSecondary.length - 1].components.length >= 5){ - nComponentsSecondary.push(new ComponentActionRow({})) + // 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); } } @@ -303,13 +340,40 @@ class DynamicCardStack { 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; @@ -317,25 +381,24 @@ class DynamicCardStack { } /** - * Handles an interaction from the attached components + * 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(); + //this.lastInteraction = Date.now(); - // should be a built-in button + // Built-in Buttons + // TODO: derive this from a constant 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() + data: this.nextCard() }) case "previous": return ctx.respond({ @@ -348,48 +411,72 @@ class DynamicCardStack { return; } - // interactive buttons + // 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){ - 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)}) + data: Object.assign(this.getCurrentCard(), { components: this._renderComponents(false)}) }) } else this.currentSelectedSubcategory = ctx.data.customId; - console.log("new category is " + this.currentSelectedSubcategory) - + // Reset page index so the new stack starts on page 0 this.index = 0; 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.currentPage(), {components:this._renderComponents(false)}) + data: Object.assign(this.getCurrentCard(), {components:this._renderComponents(false)}) }) return; } else { - let processingEmbed = page(createEmbed("default", ctx, { - image: { - url: STATIC_ASSETS.card_skeleton - } - })) + // 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 + } + })) - 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)}) - }) + // 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. this.activeCardStack = 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 + + // 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]); } @@ -400,11 +487,20 @@ class DynamicCardStack { ] console.log("resolve failed:") console.log(e) + // TODO: better errors maybe? } - setTimeout(()=>{ - return this._edit(Object.assign(this.currentPage(), {components:this._renderComponents()})) - }, 1500) + // 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 { + setTimeout(()=>{ + return this._edit(Object.assign(this.getCurrentCard(), {components:this._renderComponents()})) + }, 1500) + } return; } diff --git a/labscore/cardstack/index.js b/labscore/cardstack/index.js index e69de29..132d298 100644 --- a/labscore/cardstack/index.js +++ b/labscore/cardstack/index.js @@ -0,0 +1,7 @@ +const { DynamicCardStack } = require("./DynamicCardStack"); + +module.exports = { + createDynamicCardStack: (context, options)=>{ + return new DynamicCardStack(context, options) + } +} \ No newline at end of file diff --git a/package.json b/package.json index f56791a..2b10530 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "imports": { "#api": "./labscore/api/index.js", + "#cardstack": "./labscore/cardstack/index.js", "#client": "./labscore/client.js", "#constants": "./labscore/constants.js", "#logging": "./labscore/logging.js", From 3e0078979cee88a80ee91f9daee905568b21e4fc Mon Sep 17 00:00:00 2001 From: bignutty <3515180-bignutty@users.noreply.gitlab.com> Date: Thu, 13 Feb 2025 02:36:56 +0100 Subject: [PATCH 8/9] [cardstack] add feedback notice --- labscore/cardstack/DynamicCardStack.js | 11 ++++++++--- labscore/constants.js | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/labscore/cardstack/DynamicCardStack.js b/labscore/cardstack/DynamicCardStack.js index 601d6bb..cbb2bb0 100644 --- a/labscore/cardstack/DynamicCardStack.js +++ b/labscore/cardstack/DynamicCardStack.js @@ -1,5 +1,5 @@ const { createEmbed, page } = require("#utils/embed"); -const { iconAsEmojiObject } = require("#utils/markdown"); +const { iconAsEmojiObject, icon, link} = require("#utils/markdown"); const { editOrReply } = require("#utils/message"); const { STATIC_ASSETS } = require("#utils/statics"); @@ -7,6 +7,7 @@ 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 {DISCORD_INVITES} = require("#constants"); /** * Stores all active card stacks @@ -174,7 +175,11 @@ class DynamicCardStack { * @returns {*} */ getCardByIndex(index){ - return this.activeCardStack[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; } /** @@ -241,7 +246,7 @@ class DynamicCardStack { * @returns {Message} Card */ getCurrentCard(){ - return this.getCardByIndex(this.index); + return this.getCardByIndex(this.index) } /** diff --git a/labscore/constants.js b/labscore/constants.js index 7e0245f..145ab22 100644 --- a/labscore/constants.js +++ b/labscore/constants.js @@ -5,7 +5,8 @@ module.exports.DISCORD_INVITES = Object.freeze({ support: "https://discord.gg/8c4p6xcjru", privacy: "https://discord.gg/sQs8FhcTGh", invite: "https://discord.gg/cHd28DrM7f", - help: "https://discord.gg/xQNBB3WFne" + help: "https://discord.gg/xQNBB3WFne", + feedback_cardstack: "https://discord.gg/mEWZqhHV3S", }) module.exports.DEFAULT_BOT_NAME = 'labsCore' From 4ca49289c09895022a700ddee49fca2033a390b0 Mon Sep 17 00:00:00 2001 From: bignutty <3515180-bignutty@users.noreply.gitlab.com> Date: Thu, 13 Feb 2025 02:39:17 +0100 Subject: [PATCH 9/9] [cardstack] add built-in buttons constant --- labscore/cardstack/DynamicCardStack.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/labscore/cardstack/DynamicCardStack.js b/labscore/cardstack/DynamicCardStack.js index cbb2bb0..03a03b0 100644 --- a/labscore/cardstack/DynamicCardStack.js +++ b/labscore/cardstack/DynamicCardStack.js @@ -15,9 +15,14 @@ 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({ - "next": "button_chevron_right", - "previous": "button_chevron_left" + [BUILT_IN_BUTTON_TYPES.NEXT_PAGE]: "button_chevron_right", + [BUILT_IN_BUTTON_TYPES.PREVIOUS_PAGE]: "button_chevron_left" }); const STACK_CACHE_KEYS = Object.freeze({ @@ -397,8 +402,7 @@ class DynamicCardStack { //this.lastInteraction = Date.now(); // Built-in Buttons - // TODO: derive this from a constant - if(["next","previous"].includes(ctx.data.customId)){ + if(Object.values(BUILT_IN_BUTTON_TYPES).includes(ctx.data.customId)){ switch(ctx.data.customId){ case "next": return ctx.respond({