[nextgen/cardstack] big improvements

- Implement return types
- page numbers
- better subpage resolve delay
- error handling and system error cards
- code cleanup
This commit is contained in:
bignutty 2025-02-14 02:12:26 +01:00
parent 7e850e5b5d
commit 18bd28591c
6 changed files with 249 additions and 125 deletions

View file

@ -2,6 +2,7 @@ const { createEmbed, page } = require("#utils/embed");
const { acknowledge } = require("#utils/interactions"); const { acknowledge } = require("#utils/interactions");
const { DynamicCardStack } = require("../../../labscore/cardstack/DynamicCardStack"); const { DynamicCardStack } = require("../../../labscore/cardstack/DynamicCardStack");
const {CARD_STACK_CONSTANTS} = require("#cardstack");
module.exports = { module.exports = {
label: "text", label: "text",
@ -25,6 +26,9 @@ module.exports = {
createEmbed("default", context, { description: "page 1"}), createEmbed("default", context, { description: "page 1"}),
createEmbed("default", context, { description: "page 2. this has a conditional button."}) createEmbed("default", context, { description: "page 2. this has a conditional button."})
].map((p, index)=>page(p, {}, { key: `t_${index}` })), ].map((p, index)=>page(p, {}, { key: `t_${index}` })),
pageNumberGenerator: (pg)=>{
return `Test ${pg.index}`
},
interactive: { interactive: {
always_active_button: { always_active_button: {
label: "single sub page", label: "single sub page",
@ -32,9 +36,12 @@ module.exports = {
visible: true, visible: true,
disableCache: true, disableCache: true,
resolvePage: ()=>{ resolvePage: ()=>{
return [ return {
createEmbed("success", context, "smiley") type: CARD_STACK_CONSTANTS.RESOLVE_CALLBACK_TYPES.SUBSTACK,
].map((p)=>page(p)) cards: [
createEmbed("success", context, "smiley")
].map((p)=>page(p))
}
} }
}, },
conditional_button: { conditional_button: {
@ -46,16 +53,20 @@ module.exports = {
return (page.getState("key") === "t_1") return (page.getState("key") === "t_1")
}, },
resolvePage: (pg) => { resolvePage: (pg) => {
return [ return {
createEmbed("default", context, { description: "this is a conditional sub page"}), type: CARD_STACK_CONSTANTS.RESOLVE_CALLBACK_TYPES.SUBSTACK,
createEmbed("default", context, { description: "this is a conditional sub page two"}) cards: [
].map((p)=>page(p)); 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: { dynamic_button: {
// Button Label // Button Label
label: (page) => { label: (page) => {
return page.getState("key"); console.log(page.getState("key"))
return page.getState("key") || "test";
}, },
// Next to pagination or new row // Next to pagination or new row
inline: false, inline: false,
@ -63,67 +74,19 @@ module.exports = {
// Renders the loading state card // Renders the loading state card
renderLoadingState: (page) => { renderLoadingState: (page) => {
return createEmbed("default", context, { return createEmbed("default", context, {
description: "-# Subpage Loading :)", description: "-# replacing papa card",
}) })
}, },
resolvePage: async (pg) => { resolvePage: async (pg) => {
console.log("resolving page") console.log("resolving page")
return [ return {
createEmbed("default", context, { description: "this is a dynamic sub page " + Math.random()}), type: CARD_STACK_CONSTANTS.RESOLVE_CALLBACK_TYPES.REPLACE_PARENT_CARD,
createEmbed("default", context, { description: "this is a dynamic sub page " + Math.random()}), card: page(createEmbed("default", context, { description: "this is the new over lord " + new Date()}), {}, {
createEmbed("default", context, { description: "this is a dynamic sub page " + Math.random()}), key: Date.now()
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){ }catch(e){

View file

@ -1,10 +1,10 @@
const { anime, animeSupplemental} = require('#api'); const { anime, animeSupplemental} = require('#api');
const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES, COLORS_HEX} = require('#constants'); const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES, COLORS_HEX} = require('#constants');
const { createDynamicCardStack } = require("#cardstack"); const { createDynamicCardStack, CARD_STACK_CONSTANTS } = require("#cardstack");
const { hexToDecimalColor } = require("#utils/color"); const { hexToDecimalColor } = require("#utils/color");
const { createEmbed, formatPaginationEmbeds, page } = require('#utils/embed'); const { createEmbed, page } = require('#utils/embed');
const { acknowledge } = require('#utils/interactions'); const { acknowledge } = require('#utils/interactions');
const { smallPill, link, pill, stringwrapPreserveWords, timestamp, TIMESTAMP_FLAGS} = require('#utils/markdown'); const { smallPill, link, pill, stringwrapPreserveWords, timestamp, TIMESTAMP_FLAGS} = require('#utils/markdown');
const { editOrReply } = require('#utils/message'); const { editOrReply } = require('#utils/message');
@ -106,7 +106,7 @@ module.exports = {
if(!pages.length) return editOrReply(context, createEmbed("warning", context, `No results found.`)) if(!pages.length) return editOrReply(context, createEmbed("warning", context, `No results found.`))
createDynamicCardStack(context, { createDynamicCardStack(context, {
cards: formatPaginationEmbeds(pages), cards: pages,
interactive: { interactive: {
episodes_button: { episodes_button: {
label: "Episodes", label: "Episodes",
@ -152,7 +152,15 @@ module.exports = {
return page(card) return page(card)
}) })
return formatPaginationEmbeds(cards); return {
type: CARD_STACK_CONSTANTS.RESOLVE_CALLBACK_TYPES.SUBSTACK,
cards: cards.length >= 1 ? cards : [
// This happens if the episode metadata resolver fails.
page(createEmbed("defaultNoFooter", context, {
description: `-# ${pg.getState("name")} **Episodes**\n## Episodes Unavailable\n\nWe're unable to display episode details for this content.`
}))
],
};
} }
}, },
characters_button: { characters_button: {
@ -190,7 +198,10 @@ module.exports = {
return page(card) return page(card)
}) })
return formatPaginationEmbeds(cards); return {
type: CARD_STACK_CONSTANTS.RESOLVE_CALLBACK_TYPES.SUBSTACK,
cards: cards
};
} }
}, },
related_button: { related_button: {
@ -214,9 +225,12 @@ module.exports = {
let cards = episodes.response.body.relations.map((e) => renderAnimeResultsPage(context, e, false)) let cards = episodes.response.body.relations.map((e) => renderAnimeResultsPage(context, e, false))
return formatPaginationEmbeds(cards); return {
type: CARD_STACK_CONSTANTS.RESOLVE_CALLBACK_TYPES.SUBSTACK,
cards: cards
};
} }
}, }
} }
}); });
}catch(e){ }catch(e){

View file

@ -0,0 +1,33 @@
module.exports.BUILT_IN_BUTTON_TYPES = Object.freeze({
NEXT_PAGE: "next",
PREVIOUS_PAGE: "previous"
})
module.exports.DEFAULT_BUTTON_ICON_MAPPINGS = Object.freeze({
[this.BUILT_IN_BUTTON_TYPES.NEXT_PAGE]: "button_chevron_right",
[this.BUILT_IN_BUTTON_TYPES.PREVIOUS_PAGE]: "button_chevron_left"
});
module.exports.STACK_CACHE_KEYS = Object.freeze({
RESULT_CARDS: 0
})
/**
* Callback Types for a Dynamic Card Stack
* Component resolve.
*
* - `SUBSTACK` - Creates a "submenu" with a brand new cardstack
* - `REPLACE_PARENT` - Replaces the parent card in the root stack
* - This callback type will also unselect the button
* - `REPLACE_ROOT_STACK` - Replaces the root stack
* - This callback type will also unselect the button
*/
module.exports.RESOLVE_CALLBACK_TYPES = Object.freeze({
SUBSTACK: 0,
REPLACE_PARENT_CARD: 1,
REPLACE_STACK: 2,
0: "SUBSTACK",
1: "REPLACE_PARENT_CARD",
2: "REPLACE_STACK",
})

View file

@ -1,5 +1,5 @@
const { createEmbed, page } = require("#utils/embed"); const { createEmbed, page } = require("#utils/embed");
const { iconAsEmojiObject, icon, link} = require("#utils/markdown"); const { iconAsEmojiObject, icon, link, codeblock } = require("#utils/markdown");
const { editOrReply } = require("#utils/message"); const { editOrReply } = require("#utils/message");
const { STATIC_ASSETS } = require("#utils/statics"); const { STATIC_ASSETS } = require("#utils/statics");
@ -8,6 +8,7 @@ const { MessageComponentTypes, InteractionCallbackTypes } = require("detritus-cl
const { Message } = require("detritus-client/lib/structures"); const { Message } = require("detritus-client/lib/structures");
const { ComponentContext, Components, ComponentActionRow} = require("detritus-client/lib/utils"); const { ComponentContext, Components, ComponentActionRow} = require("detritus-client/lib/utils");
const {DISCORD_INVITES} = require("#constants"); const {DISCORD_INVITES} = require("#constants");
const {DEFAULT_BUTTON_ICON_MAPPINGS, STACK_CACHE_KEYS, BUILT_IN_BUTTON_TYPES, RESOLVE_CALLBACK_TYPES} = require("./Constants");
/** /**
* Stores all active card stacks * Stores all active card stacks
@ -15,20 +16,6 @@ const {DISCORD_INVITES} = require("#constants");
*/ */
const activeStacks = new 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 * DynamicCardStack represents an interactive stacks
* of cards (embeds) for the user to paginate through * of cards (embeds) for the user to paginate through
@ -39,13 +26,15 @@ class DynamicCardStack {
* Creates a new DynamicCardStack * Creates a new DynamicCardStack
* @param {Context} context Context * @param {Context} context Context
* @param {Object} options DynamicCardStack Arguments * @param {Object} options DynamicCardStack Arguments
* @param {Array<string>} options.buttons CardStack built-in navigation buttons * @param {Array<string>} options.buttons Card Stack built-in navigation buttons
* @param {Array} options.cards Baseline CardStack * @param {Array} options.cards Root Card Stack
* @param {Object} options.interactive Interactive Components * @param {Object} options.interactive Interactive Components
* @param {Number} options.startingIndex Starting card index * @param {Number} options.startingIndex Starting card index
* @param {boolean} options.loop Wrap paging * @param {boolean} options.loop Wrap paging
* @param {number} options.expires CardStack timeout * @param {number} options.expires Timeout for the Card Stack listener.
* @param {boolean} options.disableStackCache Allows disabling the stack result cache, meaning that every trigger will reevaluate a stack * @param {boolean} options.disableStackCache Allows disabling the stack result cache, meaning that every trigger will reevaluate a stack
* @param {boolean} options.pageNumbers Renders Page Numbers in the footer of all embeds in cards.
* @param {Function} options.pageNumberGenerator Function that renders a page number. Default style is `Page <index>/<total>`
*/ */
constructor(context, options){ constructor(context, options){
this.context = context; this.context = context;
@ -54,9 +43,12 @@ class DynamicCardStack {
this.buttons = options.buttons || ["previous","next"] this.buttons = options.buttons || ["previous","next"]
this.interactive_components = options.interactive || {}; this.interactive_components = options.interactive || {};
this.index = options.startingIndex || 0; this.index = options.startingIndex || 0;
this.rootIndex = this.index;
this.loopPages = options.loop || true; this.loopPages = options.loop || true;
this.expires = options.expires || 5*60*1000; this.expires = options.expires || 5*60*1000;
this.pageNumbers = options.pageNumbers || true;
this.pageNumberGenerator = options.pageNumberGenerator || ((pg)=>`Page ${pg.index + 1}/${pg.activeCardStack.length}`);
this.rootIndex = this.index;
this.stackCache = {}; this.stackCache = {};
this.pageState = []; this.pageState = [];
@ -138,15 +130,7 @@ class DynamicCardStack {
this.activeCardStack = [...this.cards]; this.activeCardStack = [...this.cards];
// Resolve page state for all this.updatePageState()
// 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 // Create internal component listener
this.listener = new Components({ this.listener = new Components({
@ -170,6 +154,20 @@ class DynamicCardStack {
} }
} }
/**
* Resolves page state for all root stack cards.
*/
updatePageState(){
let i = 0;
this.pageState = [];
for(const ac of this.cards){
if(ac["_meta"]){
this.pageState[i] = Object.assign({}, ac["_meta"]);
}
i++;
}
}
/** /**
* Gets a card from the currently active * Gets a card from the currently active
* stack by its index * stack by its index
@ -177,11 +175,46 @@ class DynamicCardStack {
* @returns {*} * @returns {*}
*/ */
getCardByIndex(index){ getCardByIndex(index){
// TODO: remove this some time after launch try{
let card = Object.assign({}, this.activeCardStack[index]) // TODO: remove this some time after launch
if(!card.content) card.content = ""; let card = structuredClone(this.activeCardStack[index]);
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; // This creates an error card with debug information
// in case that our activeCardStack gets corrupted
// or lost somehow (bad implementation)
if(!this.activeCardStack[index]) card = page(createEmbed("errordetail", this.context, {
error: "Unable to resolve card.",
content: `Index: \`${this.index}\`, Stack Size: \`${this.index}\`\n` +
(Object.keys(this.getAllStateForPage(this.index)).length >= 1 ?
codeblock("json", [JSON.stringify(this.getAllStateForPage(this.index), null, 2)]).substr(0, 5000) : "")
}))
if(!card.content) card.content = "";
card.content += `\n-# ${icon("flask_mini")} You are using the new page system • Leave feedback or report bugs in our ${link(DISCORD_INVITES.feedback_cardstack, "Discord Server", "labsCore Support", false)}!`
// Render Page Numbers.
// Conditions:
// - We have more than one card in the active stack
// - We have embeds in the stack
if(this.pageNumbers && card.embeds?.length && this.activeCardStack.length >= 2){
card.embeds = card.embeds.map((e)=>{
if(!e.footer) e.footer = { text: this.pageNumberGenerator(this) }
else {
if(e.footer.text) e.footer.text += `${this.pageNumberGenerator(this)}`;
else e.footer.text = this.pageNumberGenerator(this);
}
return e;
})
}
return card;
}catch(e){
console.log(e)
return page(createEmbed("errordetail", this.context, {
error: "Unable to render card:",
content: codeblock("js",[(e ? e.stack || e.message : e).replaceAll(process.cwd(), '')])
}))
}
} }
/** /**
@ -270,6 +303,16 @@ class DynamicCardStack {
return this.pageState; return this.pageState;
} }
/**
* Returns all state for a specific page.
* Only really intended for debugging purposes.
* @returns {Object}
*/
getAllStateForPage(index){
return this.pageState[index] || {};
}
/** /**
* Renders components and button states * Renders components and button states
*/ */
@ -433,8 +476,7 @@ class DynamicCardStack {
} }
else this.currentSelectedSubcategory = ctx.data.customId; else this.currentSelectedSubcategory = ctx.data.customId;
// Reset page index so the new stack starts on page 0 let resolveTime = Date.now();
this.index = 0;
try{ try{
// If we have a cached result, retrieve it // If we have a cached result, retrieve it
@ -472,28 +514,82 @@ class DynamicCardStack {
} }
// Compute the active cardstack. // Compute the active cardstack.
this.activeCardStack = await this.interactive_components[ctx.data.customId].resolvePage(this); let resolvedNewStack = await this.interactive_components[ctx.data.customId].resolvePage(this);
// TODO: this needs several modes/callback types. if(!Object.values(RESOLVE_CALLBACK_TYPES).includes(resolvedNewStack.type))
// SUBSTACK - Creates a "submenu" with a brand new cardstack throw new Error(`Invalid Stack Resolve Type (${resolvedNewStack.type})`);
// REPLACE_PARENT - Replaces the parent card in the root stack
// REPLACE_ROOT_STACK - Replaces the root stack
// Cache the computed cardstack for future accessing. switch(resolvedNewStack.type){
// The cache can be disabled/bypassed if we either /**
// a) have really big/complex results * SUBSTACK
// b) want to ensure data is always fresh *
if(!this.interactive_components[ctx.data.customId].disableCache){ * Replace the currently active paging
this._setCachedValue(this.rootIndex, ctx.data.customId, STACK_CACHE_KEYS.RESULT_CARDS, [...this.activeCardStack]); * with a new, separate card stack to
* page through.
*/
case RESOLVE_CALLBACK_TYPES.SUBSTACK:
this.activeCardStack = resolvedNewStack.cards;
this.index = resolvedNewStack.index || 0;
// Cache the computed cardstack for future accessing.
// The cache can be disabled/bypassed if we either
// a) have really big/complex results
// b) want to ensure data is always fresh
// We currently only cache SUBSTACK responses, as the other
// types probably need revalidating/refetching since the parent
// has changed and might carry new data.
if(!this.interactive_components[ctx.data.customId].disableCache){
this._setCachedValue(this.rootIndex, ctx.data.customId, STACK_CACHE_KEYS.RESULT_CARDS, [...this.activeCardStack]);
}
break;
/**
* REPLACE_PARENT_CARD
*
* Replaces the parent card (the one this action
* was initiated from) with a new one.
*
* Re-resolves all page state.
* Unselects the button.
*/
case RESOLVE_CALLBACK_TYPES.REPLACE_PARENT_CARD:
this.cards[this.rootIndex] = resolvedNewStack.card;
this.activeCardStack = [...this.cards];
this.updatePageState();
this.index = resolvedNewStack.index || this.rootIndex;
this.currentSelectedSubcategory = null;
break;
/**
* REPLACE_STACK
*
* Replaces the entire parent
* card stack with a new set.
*
* Re-resolves all page state.
* Unselects the button.
*/
case RESOLVE_CALLBACK_TYPES.REPLACE_STACK:
this.activeCardStack = resolvedNewStack.cards;
this.updatePageState();
this.index = resolvedNewStack.index || this.rootIndex;
this.currentSelectedSubcategory = null;
break;
} }
} }
} catch(e){ } catch(e){
this.activeCardStack = [ // Display an error if we're NOT
page(createEmbed("error", ctx, "Stack rendering failed.")) // in the root stack (that would break
] // things badly).
if(this.currentSelectedSubcategory != null)
this.activeCardStack = [
page(createEmbed("errordetail", ctx, {
error: "Card stack rendering failed.",
content: codeblock("js",[(e ? e.stack || e.message : e).replaceAll(process.cwd(), '')])
}))
]
console.log("resolve failed:") console.log("resolve failed:")
console.log(e) console.log(e)
// TODO: better errors maybe?
} }
// Update the card stack with a card from the new stack. // Update the card stack with a card from the new stack.
@ -503,9 +599,20 @@ class DynamicCardStack {
data: Object.assign(this.getCurrentCard(), { components: this._renderComponents()}) data: Object.assign(this.getCurrentCard(), { components: this._renderComponents()})
}) })
} else { } else {
setTimeout(()=>{ // This timeout exists 1. for cosmetic reasons so people can
return this._edit(Object.assign(this.getCurrentCard(), {components:this._renderComponents()})) // see the skeleton state and 2. in order to avoid a really
}, 1500) // annoying race condition with the media proxy reverting our
// embed to a prior state.
// If we've already waited at least 2 seconds during processing
// it *should* be safe to just edit the message now.
if((Date.now() - resolveTime) < 2000){
setTimeout(()=>{
return this._edit(Object.assign(this.getCurrentCard(), {components:this._renderComponents()}))
}, 1500)
} else {
await this._edit(Object.assign(this.getCurrentCard(), {components:this._renderComponents()}))
}
} }
return; return;

View file

@ -3,5 +3,6 @@ const { DynamicCardStack } = require("./DynamicCardStack");
module.exports = { module.exports = {
createDynamicCardStack: (context, options)=>{ createDynamicCardStack: (context, options)=>{
return new DynamicCardStack(context, options) return new DynamicCardStack(context, options)
} },
CARD_STACK_CONSTANTS: require("./constants"),
} }

View file

@ -148,6 +148,12 @@ module.exports.createEmbed = function(type, context, content){
} }
// Adds formatted page numbers to the embed footer // Adds formatted page numbers to the embed footer
/**
* Formats embeds for pagination.
* @deprecated No longer necessary in DynamicCardStack.
* @param embeds Array of Messages
* @returns {Embed[]}
*/
module.exports.formatPaginationEmbeds = function(embeds){ module.exports.formatPaginationEmbeds = function(embeds){
// No formatting if we only have one page // No formatting if we only have one page
if(embeds.length == 1) return embeds; if(embeds.length == 1) return embeds;