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("