diff --git a/commands/interaction/slash/search/anime.js b/commands/interaction/slash/search/anime.js index a7486a6..1f9ca59 100644 --- a/commands/interaction/slash/search/anime.js +++ b/commands/interaction/slash/search/anime.js @@ -1,12 +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 { createEmbed, formatPaginationEmbeds, page, hexToEmbedColor } = require('#utils/embed'); +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, { @@ -38,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) { @@ -58,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 }); } @@ -97,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){ @@ -105,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/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 293bc04..a1eac10 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,117 @@ 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: { + always_active_button: { + label: "single sub page", + inline: true, + visible: true, + disableCache: true, + resolvePage: ()=>{ + return [ + createEmbed("success", context, "smiley") + ].map((p)=>page(p)) + } + }, + conditional_button: { + // Button Label + label: "Conditional", + // Next to pagination or new row + inline: false, + visible: (page) => { + return (page.getState("key") === "t_1") + }, + resolvePage: (pg) => { + return [ + createEmbed("default", context, { description: "this is a conditional sub page"}), + createEmbed("default", context, { description: "this is a conditional sub page two"}) + ].map((p)=>page(p)); + } + }, + 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: async (pg) => { + console.log("resolving page") + return [ + createEmbed("default", context, { description: "this is a dynamic sub page " + Math.random()}), + createEmbed("default", context, { description: "this is a dynamic sub page " + Math.random()}), + createEmbed("default", context, { description: "this is a dynamic sub page " + Math.random()}), + createEmbed("default", context, { description: "this is a dynamic sub page " + Math.random()}) + ].map((p)=>page(p)); + } + }, + 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){ + console.log(e) + } } }; \ No newline at end of file diff --git a/commands/message/search/anime.js b/commands/message/search/anime.js index f8e8b4a..84ec389 100644 --- a/commands/message/search/anime.js +++ b/commands/message/search/anime.js @@ -1,11 +1,14 @@ -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 { createEmbed, formatPaginationEmbeds, page, hexToEmbedColor } = require('#utils/embed'); +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, stringwrap, stringwrapPreserveWords} = require('#utils/markdown'); +const { smallPill, link, pill, stringwrapPreserveWords, timestamp, TIMESTAMP_FLAGS} = require('#utils/markdown'); const { editOrReply } = require('#utils/message'); +const { STATIC_ASSETS } = require("#utils/statics"); function renderAnimeResultsPage(context, res){ let result = createEmbed("default", context, { @@ -37,7 +40,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) { @@ -57,7 +60,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 }); } @@ -85,7 +94,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){ @@ -94,57 +103,102 @@ 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.`)) } - }, + } }; \ No newline at end of file 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 new file mode 100644 index 0000000..03a03b0 --- /dev/null +++ b/labscore/cardstack/DynamicCardStack.js @@ -0,0 +1,521 @@ +const { createEmbed, page } = require("#utils/embed"); +const { iconAsEmojiObject, icon, link} = 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 {DISCORD_INVITES} = require("#constants"); + +/** + * Stores all active card stacks + * @type {WeakMap} + */ +const activeStacks = new WeakMap(); + +const BUILT_IN_BUTTON_TYPES = Object.freeze({ + NEXT_PAGE: "next", + PREVIOUS_PAGE: "previous" +}) + +const DEFAULT_BUTTON_ICON_MAPPINGS = Object.freeze({ + [BUILT_IN_BUTTON_TYPES.NEXT_PAGE]: "button_chevron_right", + [BUILT_IN_BUTTON_TYPES.PREVIOUS_PAGE]: "button_chevron_left" +}); + +const STACK_CACHE_KEYS = Object.freeze({ + RESULT_CARDS: 0 +}) + +/** + * DynamicCardStack represents an interactive stacks + * of cards (embeds) for the user to paginate through + * 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 + * @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; + + this.cards = options.cards || []; + this.buttons = options.buttons || ["previous","next"] + this.interactive_components = options.interactive || {}; + this.index = options.startingIndex || 0; + this.rootIndex = this.index; + this.loopPages = options.loop || true; + this.expires = options.expires || 5*60*1000; + + this.uniqueId = (Date.now()*Math.random()).toString(36); + + this.stackCache = {}; + this.pageState = []; + this.currentSelectedSubcategory = null; + + /* + this.lastInteraction = Date.now(); + this.spawned = 0; + */ + + this._spawn(); + } + + /** + * Kills the dynamic card stack. + */ + async kill(clearComponents){ + console.log("killing " + this.uniqueId) + clearTimeout(this.timeout); + + this.listener.clear(); + if(clearComponents) await this._edit(this.getCurrentCard(), []) + + // Remove reference to free the cardstack for GC + activeStacks.delete(this.context.message || this.context.interaction); + } + + /** + * Get a Stack from an attached reference (message/interaction). + * @param {Message} ref Attached message/interaction + * @returns {DynamicCardStack} + */ + _getStackByReference(ref){ + return activeStacks.get(ref); + } + + /** + * Attaches a cardstack to its internal reference. + * @private + */ + _createDynamicCardStack(){ + // Kill any previously active cardstacks on this reference + // (prevents oddities when editing a regular command) + if(activeStacks.get(this.context.message || this.context.interaction)){ + this._getStackByReference(this.context.message || this.context.interaction).kill(); + } + + activeStacks.set(this.context.message || this.context.interaction, this); + } + + /** + * Cretaes a timeout for the paginator. + * TODO: make this work properly + * @returns {number} Timeout + * @private + */ + _createTimeout(){ + return setTimeout(()=>{ + /* + // This currently isn't doable with our Components listener. + if(this.spawned - this.lastInteraction <= this.expires){ + clearTimeout(this.timeout) + this.spawned = Date.now(); + // New expiry time is 30 seconds + this.expires = 30*1000; + this.timeout = this._createTimeout(); + } else { + this.kill(true); + }*/ + this.kill(true); + }, this.expires) + } + + /** + * Creates a new cardstack in the given channel + */ + _spawn(){ + try{ + this._createDynamicCardStack(this.context.client); + + this.activeCardStack = [...this.cards]; + + // Resolve page state for all + // root pages. + let i = 0; + for(const ac of this.cards){ + if(ac["_meta"]){ + this.pageState[i] = Object.assign({}, ac["_meta"]); + } + i++; + } + + // Create internal component listener + this.listener = new Components({ + timeout: this.expires, + run: this._handleInteraction.bind(this), + onError: (e)=>{ + console.log(e) + } + }) + + this.timeout = this._createTimeout() + + //this.spawned = Date.now() + + return this._edit({ + ...this.getCurrentCard(), + components: this.listener + }); + }catch(e){ + console.log(e) + } + } + + /** + * Gets a card from the currently active + * stack by its index + * @param index Page Index + * @returns {*} + */ + getCardByIndex(index){ + // TODO: remove this some time after launch + let card = Object.assign({}, this.activeCardStack[index]) + if(!card.content) card.content = ""; + card.content += `\n-# ${icon("flask_mini")} You are using the new page system • Leave feedback or report bugs in our ${link(DISCORD_INVITES.feedback_cardstack, "Discord Server", "labsCore Support", false)}!` + return card; + } + + /** + * Advances the index and returns the next card from the stack. + * @returns {Message} Card + */ + nextCard(){ + this.index = this.index + 1; + if(this.index >= this.activeCardStack.length){ + if(this.loopPages) this.index = 0; + } + + if(this.currentSelectedSubcategory == null) this.rootIndex = this.index; + return Object.assign(this.getCardByIndex(this.index), { components: this._renderComponents() }); + } + + /** + * Decreases the index and returns the next card from the stack. + * @returns {Message} Card + */ + previousPage(){ + this.index = this.index - 1; + if(this.index < 0){ + if(this.loopPages) this.index = this.activeCardStack.length - 1; + else this.index = 0; + } + + if(this.currentSelectedSubcategory == null) this.rootIndex = this.index; + return Object.assign(this.getCardByIndex(this.index), { components: this._renderComponents() }); + } + + /** + * Edits the cardstack message. + * Automatically applies and rerenders components. + * @param {Message} cardContent Card Content + * @param {boolean, Array} components Custom Components Array + */ + async _edit(cardContent, components = false){ + let message = Object.assign({}, cardContent); + + if(!components){ + this.listener.components = this._renderComponents(); + message.components = this.listener; + } else { + message.components = components; + } + + if(message["_meta"]) delete message["_meta"]; + + try{ + return editOrReply(this.context, { + ...message, + reference: true, + allowedMentions: {parse: [], repliedUser: false} + }) + }catch(e){ + console.log(e) + } + } + + /** + * Returns the currently selected card from the + * active stack. + * @returns {Message} Card + */ + getCurrentCard(){ + return this.getCardByIndex(this.index) + } + + /** + * Retrieves state from the currently active root card + * @param {String} key + */ + getState(key){ + if(!this.pageState[this.rootIndex]) return null; + if(!this.pageState[this.rootIndex][key]) return null; + return this.pageState[this.rootIndex][key]; + } + + /** + * Returns all page state. + * Only really intended for debugging purposes. + * @returns {Object} + */ + getAllState(){ + return this.pageState; + } + + /** + * Renders components and button states + */ + _renderComponents(disabled = false){ + let nComponents = new ComponentActionRow({}) + let nComponentsSecondary = [new ComponentActionRow({})] + + // First Row always starts with built-in components + for(const b of this.buttons){ + let btn = { + type: MessageComponentTypes.BUTTON, + customId: b, + style: 2, + disabled: this.activeCardStack.length === 1 || disabled, + emoji: iconAsEmojiObject(DEFAULT_BUTTON_ICON_MAPPINGS[b]) + } + + nComponents.addButton(btn) + } + + for(const b of Object.keys(this.interactive_components)){ + let button = this.interactive_components[b]; + + // Validate if the component should be visible on this page. + // If a function is provided we need to execute it. + if(typeof(button.visible) === "boolean" && button.visible === false) continue; + else if(typeof(button.visible) === "function" && !button.visible(this)) continue; + + let component = { + type: MessageComponentTypes.BUTTON, + customId: b, + style: button.style || 2, + disabled: disabled + } + + // Dynamic disabling + if(!disabled && button.condition && typeof(button.condition) == "function") + component.disabled = !button.condition(this); + + // Dynamic label + if(button.label){ + if(typeof(button.label) === "function") component.label = button.label(this); + else component.label = button.label; + } + + if(button.icon) component.emoji = iconAsEmojiObject(button.icon) || undefined + + // Change color if this is the active button. + // TODO: allow overwriting the "active" color + if(this.currentSelectedSubcategory === b) component.style = 1; + + // Insert the component at the correct slot. + if(button.inline){ + // Ensure there is enough space for an inline component. + if(nComponents.components.length >= 5){ + // Ensure there is space on secondary rows. + if(nComponentsSecondary[nComponentsSecondary.length - 1].components.length >= 5) + nComponentsSecondary.push(new ComponentActionRow({})) + + nComponentsSecondary[nComponentsSecondary.length - 1].addButton(component); + } else { + nComponents.addButton(component); + } + } else { + // Ensure there is space on secondary rows to insert + // the component. + if(nComponentsSecondary[nComponentsSecondary.length - 1].components.length >= 5) + nComponentsSecondary.push(new ComponentActionRow({})) + + nComponentsSecondary[nComponentsSecondary.length - 1].addButton(component); + } + } + + if(nComponentsSecondary[0].components.length >= 1) return [nComponents, ...nComponentsSecondary] + return [nComponents]; + } + + /** + * Compute Cache + * + * The compute cache allows storing computed values + * (i.e. resulting card stacks) in order to skip + * refetching or reprocessing substacks when not + * necessary. The cache can be disabled per-component. + */ + + /** + * Set an internal cached computed value. + * @param index Root Card Index + * @param componentId Component ID + * @param key Cache Key + * @param value Cache Data + * @private + */ + _setCachedValue(index, componentId, key, value){ + if(!this.stackCache[index]) this.stackCache[index] = {}; + if(!this.stackCache[index][componentId]) this.stackCache[index][componentId] = {}; + this.stackCache[index][componentId][key] = value; + } + + /** + * Retrieve an internal cached computed value. + * @param index Root Card Index + * @param componentId Component ID + * @param key Cache Key + * @returns {*|null} Cached Data + * @private + */ + _getCachedValue(index, componentId, key){ + if(this.interactive_components[componentId].disableCache) return null; + + if(!this.stackCache[index]) return null; + if(!this.stackCache[index][componentId]) return null; + if(!this.stackCache[index][componentId][key]) return null; + return this.stackCache[index][componentId][key]; + } + + /** + * Handles an interaction from the attached components. + * @param {ComponentContext} ctx + */ + async _handleInteraction(ctx){ + if(ctx.user.id !== this.context.user.id) return ctx.respond({ + type: InteractionCallbackTypes.DEFERRED_UPDATE_MESSAGE + }) + + //this.lastInteraction = Date.now(); + + // Built-in Buttons + if(Object.values(BUILT_IN_BUTTON_TYPES).includes(ctx.data.customId)){ + switch(ctx.data.customId){ + case "next": + return ctx.respond({ + type: InteractionCallbackTypes.UPDATE_MESSAGE, + data: this.nextCard() + }) + case "previous": + return ctx.respond({ + type: InteractionCallbackTypes.UPDATE_MESSAGE, + data: this.previousPage() + }) + default: + console.error("unknown button??") + } + return; + } + + // Interactive Components + if(this.interactive_components[ctx.data.customId]){ + // If the selected button is already active, disable it + // and restore the root stack at its previous index. + if(this.currentSelectedSubcategory === ctx.data.customId){ + this.activeCardStack = [...this.cards]; + this.index = this.rootIndex; + this.currentSelectedSubcategory = null; + + return await ctx.respond({ + type: InteractionCallbackTypes.UPDATE_MESSAGE, + data: Object.assign(this.getCurrentCard(), { components: this._renderComponents(false)}) + }) + } + else this.currentSelectedSubcategory = ctx.data.customId; + + // 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.getCurrentCard(), {components:this._renderComponents(false)}) + }) + return; + } else { + // Controls if we should display a loading (skeleton) embed while the + // new stack is being fetched/rendered. Instant results should only + // ever be used if we rely on local data or can guarantee almost-instant + // processing/fetching times. + if(!this.interactive_components[ctx.data.customId].instantResult) { + let processingEmbed = page(createEmbed("default", ctx, { + image: { + url: STATIC_ASSETS.card_skeleton + } + })) + + // Render a custom loading skeleton embed + // TODO: maybe allow several loading modes here + // i.e COPY_PARENT which will copy select fields + // from the parent embed or SKELETON_WITH_TITLE. + // -> needs iterating on visual language first + if(this.interactive_components[ctx.data.customId].renderLoadingState) + processingEmbed = page(this.interactive_components[ctx.data.customId].renderLoadingState(this)); + + await ctx.respond({ + type: InteractionCallbackTypes.UPDATE_MESSAGE, + data: Object.assign(processingEmbed, { components: this._renderComponents(true)}) + }) + } + + // Compute the active cardstack. + 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]); + } + } + } catch(e){ + this.activeCardStack = [ + page(createEmbed("error", ctx, "Stack rendering failed.")) + ] + console.log("resolve failed:") + console.log(e) + // TODO: better errors maybe? + } + + // 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; + } + + console.error("Unknown button was triggered on stack: " + ctx.data.customId); + } +} + +module.exports.DynamicCardStack = DynamicCardStack; \ No newline at end of file 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/labscore/constants.js b/labscore/constants.js index 143de18..145ab22 100644 --- a/labscore/constants.js +++ b/labscore/constants.js @@ -1,10 +1,12 @@ const { Permissions } = require("detritus-client/lib/constants") +const { decimalToHexColor } = require("#utils/color"); 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' @@ -40,6 +42,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 57edf4a..76c1148 100644 --- a/labscore/utils/embed.js +++ b/labscore/utils/embed.js @@ -180,12 +180,9 @@ 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, }) -} - -module.exports.hexToEmbedColor = (color)=>{ - return parseInt(color.split("#")[1], 16) } \ No newline at end of file diff --git a/labscore/utils/markdown.js b/labscore/utils/markdown.js index de9a2df..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; @@ -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("` } 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), 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",