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

View file

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

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

View file

@ -3,5 +3,6 @@ const { DynamicCardStack } = require("./DynamicCardStack");
module.exports = {
createDynamicCardStack: (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
/**
* Formats embeds for pagination.
* @deprecated No longer necessary in DynamicCardStack.
* @param embeds Array of Messages
* @returns {Embed[]}
*/
module.exports.formatPaginationEmbeds = function(embeds){
// No formatting if we only have one page
if(embeds.length == 1) return embeds;