From b418ec5c37cf03c47d1807df54072500c2d91034 Mon Sep 17 00:00:00 2001 From: Jeff Sandberg Date: Thu, 15 Jan 2026 17:27:59 -0700 Subject: [PATCH] Add taxon filter controls to Group Chat member list (#5006) * Add taxon controls to top of group member list * Refactor/cleanup getGroupCharacters * Fix favorites, refactor and cleanup code * Fix clearing filters, only show relevant filters in groups * Fix issues and add persistence - Fix group member tag listing requiring character list to be init to display - Fix character tag updates to actually show up in group member contexts - Persist filter changes for group member tag controls * Apply suggestions from code review Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com> * Avoid sibling selectors * Err on invalid tag type * avoid hardcoded ids * cleanup: don't use `group_member`, use `group_candidates_list` * Support both selectors and jquery instances in getFilterHelper * Sanitize missing tag filters before rendering * Unscrew jsdoc formatting * Show all tags, mark absents specially * Improve JSDoc * Fix tag indicator for group contexts * Don't use deprecated fields * Add a comment on potentially undefined group id --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com> --- public/css/tags.css | 4 + public/index.html | 8 +- public/script.js | 3 +- public/scripts/group-chats.js | 82 +++-- public/scripts/tags.js | 551 ++++++++++++++++++++++++++++++---- 5 files changed, 569 insertions(+), 79 deletions(-) diff --git a/public/css/tags.css b/public/css/tags.css index 0cfb35ebf..b291df975 100644 --- a/public/css/tags.css +++ b/public/css/tags.css @@ -67,6 +67,10 @@ display: none; } +.tag.tag-absent { + text-decoration: line-through; +} + .tag.actionable { border-radius: 50%; aspect-ratio: 1 / 1; diff --git a/public/index.html b/public/index.html index a725dbc93..bce1cb9dc 100644 --- a/public/index.html +++ b/public/index.html @@ -6126,6 +6126,12 @@
+
+ +
+
+
+
@@ -6137,7 +6143,7 @@
-
+
diff --git a/public/script.js b/public/script.js index aa990bad3..75204e8cf 100644 --- a/public/script.js +++ b/public/script.js @@ -953,7 +953,8 @@ export async function printCharacters(fullRefresh = false) { // We are actually always reprinting filters, as it "doesn't hurt", and this way they are always up to date printTagFilters(tag_filter_type.character); - printTagFilters(tag_filter_type.group_member); + printTagFilters(tag_filter_type.group_members_list); + printTagFilters(tag_filter_type.group_candidates_list); // We are also always reprinting the lists on character/group edit window, as these ones doesn't get updated otherwise applyTagsOnCharacterSelect(); diff --git a/public/scripts/group-chats.js b/public/scripts/group-chats.js index 14e8addbc..1cca72f7e 100644 --- a/public/scripts/group-chats.js +++ b/public/scripts/group-chats.js @@ -80,7 +80,7 @@ import { chatElement, ensureMessageMediaIsArray, } from '../script.js'; -import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js'; +import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect, printTagFilters, tag_filter_type } from './tags.js'; import { FILTER_TYPES, FilterHelper } from './filters.js'; import { isExternalMediaAllowed } from './chats.js'; import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; @@ -134,6 +134,7 @@ export const group_generation_mode = { export const DEFAULT_AUTO_MODE_DELAY = 5; export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, debounce_timeout.quick)); +export const groupMembersFilter = new FilterHelper(debounce(printGroupMembers, debounce_timeout.quick)); let autoModeWorker = null; const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), debounce_timeout.relaxed); /** @type {Map} */ @@ -1449,6 +1450,10 @@ async function modifyGroupMember(groupId, groupMember, isDelete) { printGroupCandidates(); printGroupMembers(); + // Refresh the tag filters for both lists to reflect any new tags + printTagFilters(tag_filter_type.group_candidates_list); + printTagFilters(tag_filter_type.group_members_list); + const groupHasMembers = getGroupCharacters({ doFilter: false, onlyMembers: true }).length > 0; $('#rm_group_submit').prop('disabled', !groupHasMembers); } @@ -1557,31 +1562,63 @@ function isGroupMember(group, avatarId) { * @returns {Array<{item: Character, id: number, type: string}>} Array of group character objects */ function getGroupCharacters({ doFilter = false, onlyMembers = false } = {}) { - function sortMembersFn(a, b) { + function applyFilterAndSort(results, filter, filterSelector) { + let filtered = results; + if (doFilter) { + filtered = filter.applyFilters(filtered); + } + const useFilterOrder = doFilter && !!$(filterSelector).val(); + sortEntitiesList(filtered, useFilterOrder, filter); + filter.clearFuzzySearchCaches(); + return filtered; + } + + function handleMembers(results, thisGroup) { const membersArray = thisGroup?.members ?? newGroupMembers; - const aIndex = membersArray.indexOf(a.item.avatar); - const bIndex = membersArray.indexOf(b.item.avatar); - return aIndex - bIndex; + + // Create index map for O(1) lookups in member sort function + // (separate from characterIndexMap which maps character objects to their array indices) + const memberIndexMap = new Map(membersArray.map((avatar, index) => [avatar, index])); + + function sortMembersFn(a, b) { + const aIndex = memberIndexMap.get(a.item.avatar) ?? -1; + const bIndex = memberIndexMap.get(b.item.avatar) ?? -1; + return aIndex - bIndex; + } + + // Apply manual member sort before filter and sort + let filtered = results; + if (doFilter) { + filtered = groupMembersFilter.applyFilters(filtered); + } + filtered.sort(sortMembersFn); + + // Apply conditional filter-based sort and cleanup + const useFilterOrder = doFilter && !!$('#rm_group_members_filter').val(); + if (useFilterOrder) { + sortEntitiesList(filtered, useFilterOrder, groupMembersFilter); + } + groupMembersFilter.clearFuzzySearchCaches(); + return filtered; } const thisGroup = openGroupId && groups.find((x) => x.id == openGroupId); - let candidates = characters + + // Create index map for O(1) lookups when mapping characters to their array indices + // (separate from memberIndexMap used later for sorting members by their group order) + const characterIndexMap = new Map(characters.map((char, index) => [char, index])); + + const results = characters .filter((x) => isGroupMember(thisGroup, x.avatar) == onlyMembers) - .map((x) => ({ item: x, id: characters.indexOf(x), type: 'character' })); + .map((x) => ({ item: x, id: characterIndexMap.get(x), type: 'character' })); - if (doFilter) { - candidates = groupCandidatesFilter.applyFilters(candidates); + // Early return for candidates (non-members) + if (!onlyMembers) { + return applyFilterAndSort(results, groupCandidatesFilter, '#rm_group_filter'); } - if (onlyMembers) { - candidates.sort(sortMembersFn); - } else { - const useFilterOrder = doFilter && !!$('#rm_group_filter').val(); - sortEntitiesList(candidates, useFilterOrder, groupCandidatesFilter); - } - - groupCandidatesFilter.clearFuzzySearchCaches(); - return candidates; + // Handle members with manual sort capability + return handleMembers(results, thisGroup); } function printGroupCandidates() { @@ -1621,7 +1658,7 @@ function printGroupMembers() { const pageSize = Number(accountStorage.getItem(storageKey)) || 5; const sizeChangerOptions = [5, 10, 25, 50, 100, 200, 500, 1000]; $(this).pagination({ - dataSource: getGroupCharacters({ doFilter: false, onlyMembers: true }), + dataSource: getGroupCharacters({ doFilter: true, onlyMembers: true }), pageRange: 1, position: 'top', showPageNumbers: false, @@ -1782,6 +1819,7 @@ function select_group_chats(groupId, skipAnimation) { $('#group_avatar_preview').empty().append(getGroupAvatar(group)); $('#rm_group_restore_avatar').toggle(!!group && isValidImageUrl(group.avatar_url)); $('#rm_group_filter').val('').trigger('input'); + $('#rm_group_members_filter').val('').trigger('input'); $('#rm_group_activation_strategy').val(replyStrategy); $(`#rm_group_activation_strategy option[value="${replyStrategy}"]`).prop('selected', true); $('#rm_group_generation_mode').val(generationMode); @@ -2049,6 +2087,11 @@ function filterGroupMembers() { groupCandidatesFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue); } +function filterGroupMemberList() { + const searchValue = String($(this).val()).toLowerCase(); + groupMembersFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue); +} + async function createGroup() { let name = $('#rm_group_chat_name').val().toString(); let allowSelfResponses = !!$('#rm_group_allow_self_responses').prop('checked'); @@ -2424,6 +2467,7 @@ jQuery(() => { openGroupById(groupId); }); $('#rm_group_filter').on('input', filterGroupMembers); + $('#rm_group_members_filter').on('input', filterGroupMemberList); $('#rm_group_submit').on('click', createGroup); $('#rm_group_scenario').on('click', setCharacterSettingsOverrides); $('#rm_group_automode').on('input', function () { diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 84d3c4fab..58a57ed63 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -15,7 +15,7 @@ import { } from '../script.js'; import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js'; -import { groupCandidatesFilter, groups, selected_group } from './group-chats.js'; +import { groupCandidatesFilter, groupMembersFilter, groups, selected_group } from './group-chats.js'; import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce, findChar } from './utils.js'; import { power_user } from './power-user.js'; import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; @@ -53,16 +53,113 @@ export { removeTagFromMap, }; -/** @typedef {import('../script.js').Character} Character */ - const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter'; -const GROUP_FILTER_SELECTOR = '#rm_group_chats_block .rm_tag_filter'; +const GROUP_FILTER_SELECTOR = '#rm_group_add_members_header ~ .rm_tag_controls .rm_tag_filter'; +const GROUP_MEMBERS_FILTER_SELECTOR = '#rm_group_members_header ~ .rm_tag_controls .rm_tag_filter'; const TAG_TEMPLATE = $('#tag_template .tag'); const FOLDER_TEMPLATE = $('#bogus_folder_template .bogus_folder_select'); const VIEW_TAG_TEMPLATE = $('#tag_view_template .tag_view_item'); +/** + * Gets the context information (selector and search input) for a filter helper. + * Used to reduce code duplication when working with different filter contexts. + * @param {FilterHelper} filterHelper - The filter helper instance + * @returns {{selector: string, searchInput: string}|null} Context info or null if unknown + */ +function getFilterContext(filterHelper) { + if (filterHelper === entitiesFilter) { + return { + selector: CHARACTER_FILTER_SELECTOR, + searchInput: '#character_search_bar', + }; + } else if (filterHelper === groupCandidatesFilter) { + return { + selector: GROUP_FILTER_SELECTOR, + searchInput: '#rm_group_filter', + }; + } else if (filterHelper === groupMembersFilter) { + return { + selector: GROUP_MEMBERS_FILTER_SELECTOR, + searchInput: '#rm_group_members_filter', + }; + } + return null; +} + +/** + * Get the filter helper for a given list selector. + * @param {string|JQuery} listSelector - jQuery selector for the list + * @returns {FilterHelper} The appropriate filter helper instance + */ function getFilterHelper(listSelector) { - return $(listSelector).is(GROUP_FILTER_SELECTOR) ? groupCandidatesFilter : entitiesFilter; + const $element = typeof listSelector === 'string' ? $(listSelector) : listSelector; + + // Check if this filter is in the group members section + if ($element.closest('#currentGroupMembers').length > 0) { + return groupMembersFilter; + } + + // Check if this filter is in the group candidates (add members) section + if ($element.closest('#unaddedCharList').length > 0) { + return groupCandidatesFilter; + } + + // Default to character list filter + return entitiesFilter; +} + +/** + * Checks if the given type is a group context. + * @param {tag_filter_type} type - The filter type to check + * @returns {boolean} True if this is a group context + */ +function isGroupContext(type) { + return [tag_filter_type.group_candidates_list, tag_filter_type.group_members_list].includes(type); +} + +/** + * Gets visible character avatars for a group context. + * @param {tag_filter_type} type - The filter type + * @param {object} currentGroup - The current group object + * @returns {string[]} Array of visible character avatars + */ +function getVisibleAvatarsForGroupContext(type, currentGroup) { + if (!currentGroup || !Array.isArray(currentGroup.members)) { + return []; + } + + switch (type) { + case tag_filter_type.group_members_list: + return currentGroup.members; + case tag_filter_type.group_candidates_list: + return characters + .filter(c => !currentGroup.members.includes(c.avatar)) + .map(c => c.avatar); + default: + console.warn('getVisibleAvatarsForGroupContext got invalid type, expected 1 or 2, got ', type); + return []; + } +} + +/** + * Filters actionable tags for group contexts. + * In group contexts, hide GROUP and FOLDER filters but keep Favorites and utility buttons. + * @param {object[]} actionTags - Array of actionable tag objects + * @returns {object[]} Filtered array of actionable tags + */ +function filterActionableTagsForGroupContext(actionTags) { + return actionTags.filter(tag => { + // Always show Favorites + if (tag.id === ACTIONABLE_TAGS.FAV.id) { + return true; + } + // Hide GROUP and FOLDER filters in group contexts (not relevant) + if (tag.id === ACTIONABLE_TAGS.GROUP.id || tag.id === ACTIONABLE_TAGS.FOLDER.id) { + return false; + } + // Show utility buttons (VIEW, HINT, UNFILTER) + return true; + }); } const ACTIONABLE_FILTER_STORAGE_KEYS = Object.freeze({ @@ -71,12 +168,79 @@ const ACTIONABLE_FILTER_STORAGE_KEYS = Object.freeze({ FOLDER: 'TagFilterState_FOLDER', }); +/** + * Gets the storage key prefix for a filter helper to enable persistence. + * @param {FilterHelper} filterHelper - The filter helper to check + * @returns {string|null} Storage key prefix or null if no persistence + */ +function getFilterStorageKey(filterHelper) { + if (filterHelper === entitiesFilter) { + return 'CharacterList'; + } else if (filterHelper === groupCandidatesFilter) { + return 'GroupCandidates'; + } else if (filterHelper === groupMembersFilter) { + return 'GroupMembers'; + } + return null; +} + +/** + * Checks if the given filter helper is the main character list filter. + * @param {FilterHelper} filterHelper - The filter helper to check + * @returns {boolean} True if this is the main character list + */ +function isMainCharacterList(filterHelper) { + return filterHelper === entitiesFilter; +} + /** @enum {number} */ export const tag_filter_type = { character: 0, + /** @deprecated use `group_candidates_list` instead */ group_member: 1, + group_candidates_list: 1, + group_members_list: 2, }; +/** + * Gets the power_user setting key for tag filter visibility for a given context. + * @param {number} type - The tag_filter_type + * @returns {string} The power_user setting key + */ +function getTagFilterVisibilitySetting(type) { + switch (type) { + case tag_filter_type.character: + return 'show_tag_filters'; + case tag_filter_type.group_candidates_list: + return 'show_tag_filters_group_candidates'; + case tag_filter_type.group_members_list: + return 'show_tag_filters_group_members'; + default: + return 'show_tag_filters'; + } +} + +/** + * Gets the tag filter visibility state for a given context. + * @param {number} type - The tag_filter_type + * @returns {boolean} Whether tag filters should be shown + */ +function getTagFilterVisibility(type) { + const settingKey = getTagFilterVisibilitySetting(type); + return power_user[settingKey] ?? false; +} + +/** + * Sets the tag filter visibility state for a given context. + * @param {number} type - The tag_filter_type + * @param {boolean} visible - Whether tag filters should be shown + */ +function setTagFilterVisibility(type, visible) { + const settingKey = getTagFilterVisibilitySetting(type); + power_user[settingKey] = visible; + saveSettingsDebounced(); +} + /** @enum {number} */ export const tag_import_setting = { ASK: 1, @@ -93,18 +257,33 @@ export const tag_sort_mode = { }; /** - * @type {{ FAV: Tag, GROUP: Tag, FOLDER: Tag, VIEW: Tag, HINT: Tag, UNFILTER: Tag }} - * A collection of global actional tags for the filter panel - * */ + * A collection of global actionable tags for the filter panel. + * + * Tags with `filter_state` property (FAV, GROUP, FOLDER) maintain persistent state: + * - Each context (character list, group candidates, group members) saves state independently + * - Main character list also maintains tag.filter_state for backward compatibility + * + * Tags without `filter_state` (VIEW, HINT, UNFILTER) are action buttons only. + */ const ACTIONABLE_TAGS = { - FAV: { id: '1', sort_order: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' }, - GROUP: { id: '0', sort_order: 2, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' }, - FOLDER: { id: '4', sort_order: 3, name: 'Show only folders', color: 'rgba(120, 120, 120, 0.5)', action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' }, + FAV: { id: '1', sort_order: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', filter_state: undefined, action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' }, + GROUP: { id: '0', sort_order: 2, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', filter_state: undefined, action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' }, + FOLDER: { id: '4', sort_order: 3, name: 'Show only folders', color: 'rgba(120, 120, 120, 0.5)', filter_state: undefined, action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' }, VIEW: { id: '2', sort_order: 4, name: 'Manage tags', color: 'rgba(150, 100, 100, 0.5)', action: onViewTagsListClick, icon: 'fa-solid fa-gear', class: 'manageTags' }, HINT: { id: '3', sort_order: 5, name: 'Show Tag List', color: 'rgba(150, 100, 100, 0.5)', action: onTagListHintClick, icon: 'fa-solid fa-tags', class: 'showTagList' }, UNFILTER: { id: '5', sort_order: 6, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' }, }; +/** + * Map of tag IDs to their corresponding filter types. + * Used for actionable tags (Favorites, Groups, Folders). + */ +const TAG_ID_TO_FILTER_TYPE = new Map([ + [ACTIONABLE_TAGS.FAV.id, FILTER_TYPES.FAV], + [ACTIONABLE_TAGS.GROUP.id, FILTER_TYPES.GROUP], + [ACTIONABLE_TAGS.FOLDER.id, FILTER_TYPES.FOLDER], +]); + /** @type {{[key: string]: Tag}} An optional list of actionables that can be utilized by extensions */ const InListActionable = { }; @@ -345,15 +524,68 @@ function getTagBlock(tag, entities, hidden = 0, isUseless = false) { } /** - * Applies the favorite filter to the character list. - * @param {FilterHelper} _filterHelper Instance of FilterHelper class. Unused since it needs to be applied to both filters. + * Common logic for applying actionable tag filters (Favorites, Groups, Folders). + * Persists state to storage for all filter contexts. + * @param {FilterHelper} filterHelper - Instance of FilterHelper class + * @param {object} tag - The actionable tag object + * @param {string} filterType - The filter type constant + * @param {string} storageKey - The storage key base for persistence */ -function filterByFav(_filterHelper) { +function applyActionableTagFilter(filterHelper, tag, filterType, storageKey) { const state = toggleTagThreeState($(this)); - ACTIONABLE_TAGS.FAV.filter_state = state; - accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.FAV, state); - entitiesFilter.setFilterData(FILTER_TYPES.FAV, state); - groupCandidatesFilter.setFilterData(FILTER_TYPES.FAV, state); + + // Persist to storage for all contexts + const storagePrefix = getFilterStorageKey(filterHelper); + if (storagePrefix) { + const contextStorageKey = `${storagePrefix}_${storageKey}`; + accountStorage.setItem(contextStorageKey, state); + } + + // Also update global state for main character list (backward compatibility) + if (isMainCharacterList(filterHelper)) { + tag.filter_state = state; + } + + // Update the filter helper for the current context + filterHelper.setFilterData(filterType, state); +} + +/** + * Determines the filter state for a tag based on context. + * For actionable tags: reads from persisted state via filter helper. + * For regular tags: reads from the filter helper's TAG filter data. + * @param {FilterHelper} filterHelper - The filter helper for the current context + * @param {object} tag - The tag object + * @param {boolean} isFilterActionable - Whether the tag is an actionable filter tag + * @returns {string} The filter state + */ +function determineTagFilterState(filterHelper, tag, isFilterActionable) { + if (isFilterActionable) { + // For actionable tags: read from filter helper (which is loaded from storage) + const filterType = TAG_ID_TO_FILTER_TYPE.get(tag.id) || null; + if (filterType) { + return filterHelper.getFilterData(filterType) || DEFAULT_FILTER_STATE; + } + } else { + // For regular tags: read from the filter helper's TAG filter data + const tagFilterData = filterHelper.getFilterData(FILTER_TYPES.TAG); + if (tagFilterData.excluded.includes(tag.id)) { + return 'EXCLUDED'; + } + if (tagFilterData.selected.includes(tag.id)) { + return 'SELECTED'; + } + } + + return DEFAULT_FILTER_STATE; +} + +/** + * Applies the favorite filter to the character list. + * @param {FilterHelper} filterHelper Instance of FilterHelper class. + */ +function filterByFav(filterHelper) { + applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.FAV, FILTER_TYPES.FAV, ACTIONABLE_FILTER_STORAGE_KEYS.FAV); } /** @@ -361,10 +593,7 @@ function filterByFav(_filterHelper) { * @param {FilterHelper} filterHelper Instance of FilterHelper class. */ function filterByGroups(filterHelper) { - const state = toggleTagThreeState($(this)); - ACTIONABLE_TAGS.GROUP.filter_state = state; - accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.GROUP, state); - filterHelper.setFilterData(FILTER_TYPES.GROUP, state); + applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.GROUP, FILTER_TYPES.GROUP, ACTIONABLE_FILTER_STORAGE_KEYS.GROUP); } /** @@ -379,10 +608,7 @@ function filterByFolder(filterHelper) { return; } - const state = toggleTagThreeState($(this)); - ACTIONABLE_TAGS.FOLDER.filter_state = state; - accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER, state); - filterHelper.setFilterData(FILTER_TYPES.FOLDER, state); + applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.FOLDER, FILTER_TYPES.FOLDER, ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER); } function loadTagsSettings(settings) { @@ -942,6 +1168,7 @@ function newTag(tagName) { * @property {boolean} [isGeneralList=false] - If true, indicates that this is the general list of tags. * @property {boolean} [skipExistsCheck=false] - If true, the tag gets added even if a tag with the same id already exists. * @property {boolean} [isCharacterList=false] - If true, indicates that this is the character's list of tags. + * @property {boolean} [isInactive=false] - If true, indicates that the tag is inactive (for styling purposes). */ /** @@ -954,6 +1181,7 @@ function newTag(tagName) { * @property {function(object): function} [tagActionSelector=undefined] - An optional override for the action property that can be assigned to each tag via tagOptions. * If set, the selector is executed on each tag as input argument. This allows a list of tags to be provided and each tag can have it's action based on the tag object itself. * @property {TagOptions} [tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList") + * @property {string[]} [inactiveTags=[]] - List of tag IDs that are considered inactive (for styling purposes). */ /** @@ -962,7 +1190,7 @@ function newTag(tagName) { * @param {JQuery|string} element - The container element where the tags are to be printed. (Optionally can also be a string selector for the element, which will then be resolved) * @param {PrintTagListOptions} [options] - Optional parameters for printing the tag list. */ -function printTagList(element, { tags = undefined, addTag = undefined, forEntityOrKey = undefined, empty = true, sort = true, tagActionSelector = undefined, tagOptions = {} } = {}) { +function printTagList(element, { tags = undefined, addTag = undefined, forEntityOrKey = undefined, empty = true, sort = true, tagActionSelector = undefined, tagOptions = {}, inactiveTags = [] } = {}) { const $element = (typeof element === 'string') ? $(element) : element; const key = forEntityOrKey !== undefined ? getTagKeyForEntity(forEntityOrKey) : getTagKey(); let printableTags = tags ? (typeof tags === 'function' ? tags() : tags) : getTagsList(key, sort); @@ -1018,7 +1246,9 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity // Check if we should print this tag if (shouldPrintTag(tag) || additionalTagsPrinted++ < availableSlotsForAdditionalTags) { - appendTagToList($element, tag, tagOptions); + // Check if this tag is in the inactive list + const isInactive = inactiveTags.includes(tag.id); + appendTagToList($element, tag, { ...tagOptions, isInactive }); } else { tagsSkipped++; } @@ -1040,7 +1270,7 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity // Do not bubble further, we are just expanding event.stopPropagation(); - printTagList($element, { tags: tags, addTag: addTag, forEntityOrKey: forEntityOrKey, empty: empty, tagActionSelector: tagActionSelector, tagOptions: tagOptions }); + printTagList($element, { tags: tags, addTag: addTag, forEntityOrKey: forEntityOrKey, empty: empty, tagActionSelector: tagActionSelector, tagOptions: tagOptions, inactiveTags: inactiveTags }); }; // Print the placeholder object with its styling and action to show the remaining tags @@ -1061,7 +1291,7 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity * @param {TagOptions} [options={}] - Options for tag behavior * @returns {void} */ -function appendTagToList(listElement, tag, { removable = false, isFilter = false, action = undefined, removeAction = undefined, isGeneralList = false, skipExistsCheck = false } = {}) { +function appendTagToList(listElement, tag, { removable = false, isFilter = false, action = undefined, removeAction = undefined, isGeneralList = false, skipExistsCheck = false, isInactive = false } = {}) { if (!listElement) { return; } @@ -1097,13 +1327,22 @@ function appendTagToList(listElement, tag, { removable = false, isFilter = false tagElement.find('.tag_name').text('').attr('title', `${translate(tag.name)} ${tag.title || ''}`.trim()).addClass(tag.icon); tagElement.addClass('actionable'); } + if (isInactive) { + tagElement.addClass('tag-absent'); + } // We could have multiple ways of actions passed in. The manual arguments have precendence in front of a specified tag action const clickableAction = action ?? tag.action; // If this is a tag for a general list and its either a filter or actionable, lets mark its current state if ((isFilter || clickableAction) && isGeneralList) { - toggleTagThreeState(tagElement, { stateOverride: tag.filter_state ?? DEFAULT_FILTER_STATE }); + const filterHelper = getFilterHelper($(listElement)); + const isFilterActionable = clickableAction && 'filter_state' in tag; + + if (isFilter || isFilterActionable) { + const filterState = determineTagFilterState(filterHelper, tag, isFilterActionable); + toggleTagThreeState(tagElement, { stateOverride: filterState }); + } } if (isFilter) { @@ -1127,16 +1366,77 @@ function onTagFilterClick(listElement) { let state = toggleTagThreeState($(this)); - if (existingTag) { + const filterHelper = getFilterHelper($(listElement)); + + // Update the tag's filter_state for the main character list (backward compatibility) + if (existingTag && isMainCharacterList(filterHelper)) { existingTag.filter_state = state; saveSettingsDebounced(); } - // We don't print anything manually, updating the filter will automatically trigger a redraw of all relevant stuff + // Persist to storage for all contexts + const storagePrefix = getFilterStorageKey(filterHelper); + if (storagePrefix && existingTag) { + const storageKey = `${storagePrefix}_tag_${tagId}`; + accountStorage.setItem(storageKey, state); + } + + // Apply all tag filters by reading from DOM state (this triggers the filter helper update) runTagFilters(listElement); // Focus the tag again we were at, if possible. To improve keyboard navigation setTimeout(() => parent.find(`.tag[id="${tagId}"]`).trigger('focus'), DEFAULT_PRINT_TIMEOUT + 1); + + updateTagFilterIndicator(listElement); +} + +/** + * Loads persisted filter states for a given filter context. + * @param {FilterHelper} filterHelper - The filter helper instance + * @param {string} storagePrefix - The storage key prefix for this context + */ +function loadFilterStatesForContext(filterHelper, storagePrefix) { + const validStates = new Set(Object.keys(FILTER_STATES)); + const readState = (/** @type {string} */ storageKey) => { + const v = accountStorage.getItem(storageKey); + return v && validStates.has(v) ? v : null; + }; + + // Load actionable tag states (Favorites, Groups, Folders) + const favState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.FAV}`); + if (favState) { + filterHelper.setFilterData(FILTER_TYPES.FAV, favState, true); + } + + const groupState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.GROUP}`); + if (groupState) { + filterHelper.setFilterData(FILTER_TYPES.GROUP, groupState, true); + } + + const folderState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER}`); + if (folderState) { + filterHelper.setFilterData(FILTER_TYPES.FOLDER, folderState, true); + } + + // Load regular tag filter states + const tagFilterData = filterHelper.getFilterData(FILTER_TYPES.TAG); + for (const tag of tags) { + const storageKey = `${storagePrefix}_tag_${tag.id}`; + const state = readState(storageKey); + + if (state) { + if (state === 'SELECTED') { + if (!tagFilterData.selected.includes(tag.id)) { + tagFilterData.selected.push(tag.id); + } + } else if (state === 'EXCLUDED') { + if (!tagFilterData.excluded.includes(tag.id)) { + tagFilterData.excluded.push(tag.id); + } + } + } + } + filterHelper.setFilterData(FILTER_TYPES.TAG, tagFilterData, true); } /** @@ -1201,20 +1501,77 @@ function runTagFilters(listElement) { } function printTagFilters(type = tag_filter_type.character) { - const FILTER_SELECTOR = type === tag_filter_type.character ? CHARACTER_FILTER_SELECTOR : GROUP_FILTER_SELECTOR; + removeMissingTagFilters(); + + let FILTER_SELECTOR; + switch (type) { + case tag_filter_type.character: + FILTER_SELECTOR = CHARACTER_FILTER_SELECTOR; + break; + case tag_filter_type.group_candidates_list: + FILTER_SELECTOR = GROUP_FILTER_SELECTOR; + break; + case tag_filter_type.group_members_list: + FILTER_SELECTOR = GROUP_MEMBERS_FILTER_SELECTOR; + break; + default: + FILTER_SELECTOR = CHARACTER_FILTER_SELECTOR; + break; + } + $(FILTER_SELECTOR).empty(); // Print all action tags. (Rework 'Folder' button to some kind of onboarding if no folders are enabled yet) - const actionTags = Object.values(ACTIONABLE_TAGS); + let actionTags = Object.values(ACTIONABLE_TAGS); actionTags.find(x => x == ACTIONABLE_TAGS.FOLDER).name = power_user.bogus_folders ? 'Show only folders' : 'Enable \'Tags as Folder\'\n\nAllows characters to be grouped in folders by their assigned tags.\nTags have to be explicitly chosen as folder to show up.\n\nClick here to start'; + + // For group contexts, filter actionable tags to only show relevant ones + if (isGroupContext(type)) { + actionTags = filterActionableTagsForGroupContext(actionTags); + } + printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: actionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } }); const inListActionTags = Object.values(InListActionable); printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: inListActionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } }); - const characterTagIds = Object.values(tag_map).flat(); - const tagsToDisplay = tags.filter(x => characterTagIds.includes(x.id)).sort(compareTagsForSort); - printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { isFilter: true, isGeneralList: true } }); + // Determine which character tags to display based on context + let tagsToDisplay; + let inactiveTags = []; + + if (isGroupContext(type)) { + // For group contexts, show all tags but mark ones without presence in current context as inactive + // CAUTION: when called by openGroupById, the selected_group variable might not yet be updated + const currentGroup = selected_group ? groups.find(x => x.id == selected_group) : null; + const visibleAvatars = getVisibleAvatarsForGroupContext(type, currentGroup); + + if (visibleAvatars.length > 0) { + // Get tags that are assigned to at least one visible character + const activeCharacterTagIds = visibleAvatars + .map(avatar => tag_map[avatar] || []) + .flat() + .filter(onlyUnique); + + // Show all tags that exist in the tag_map + const allCharacterTagIds = Object.values(tag_map).flat().filter(onlyUnique); + tagsToDisplay = tags.filter(x => allCharacterTagIds.includes(x.id)).sort(compareTagsForSort); + + // Mark tags that are not in the active set as inactive + inactiveTags = tagsToDisplay + .filter(x => !activeCharacterTagIds.includes(x.id)) + .map(x => x.id); + } else { + // No group selected, show no tags + tagsToDisplay = []; + } + } else { + // For main character list, show all tags as before + const characterTagIds = Object.values(tag_map).flat(); + tagsToDisplay = tags.filter(x => characterTagIds.includes(x.id)).sort(compareTagsForSort); + } + + printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { isFilter: true, isGeneralList: true }, inactiveTags: inactiveTags }); + // Print bogus folder navigation const bogusDrilldown = $(FILTER_SELECTOR).siblings('.rm_tag_bogus_drilldown'); @@ -1224,22 +1581,36 @@ function printTagFilters(type = tag_filter_type.character) { printTagList(bogusDrilldown, { tags: navigatedTags, tagOptions: { removable: true } }); } - runTagFilters(FILTER_SELECTOR); + // Don't call runTagFilters here - it would overwrite the loaded filter states with the DOM state. + // The visual state (CSS classes) already matches the filter helper state set by loadFilterStatesForContext. + // runTagFilters is only needed when user clicks a tag (handled in onTagFilterClick). - if (power_user.show_tag_filters) { - $('.rm_tag_controls .showTagList').addClass('selected'); - $('.rm_tag_controls').find('.tag:not(.actionable)').show(); + // Initialize the tag list visibility based on saved settings for this context + const shouldShowTags = getTagFilterVisibility(type); + const showTagListButton = $(FILTER_SELECTOR).closest('.rm_tag_controls').find('.showTagList'); + + // Update button state to match the saved setting + showTagListButton.toggleClass('selected', shouldShowTags); + + if (shouldShowTags) { + $(FILTER_SELECTOR).find('.tag:not(.actionable)').show(); + } else { + $(FILTER_SELECTOR).find('.tag:not(.actionable)').hide(); } - updateTagFilterIndicator(); + updateTagFilterIndicator(FILTER_SELECTOR); } -function updateTagFilterIndicator() { - if ($('.rm_tag_controls').find('.tag:not(.actionable)').is('.selected, .excluded')) { - $('.rm_tag_controls .showTagList').addClass('indicator'); - } else { - $('.rm_tag_controls .showTagList').removeClass('indicator'); - } +/** + * Updates the tag filter indicator based on the selected/excluded tags in the given filter selector + * @param {string|JQuery} filterSelector - The selector or jQuery element for the tag filter container + */ +function updateTagFilterIndicator(filterSelector) { + const selector = filterSelector || CHARACTER_FILTER_SELECTOR; + const tagFilter = typeof selector === 'string' ? $(selector) : selector; + const showTagListButton = tagFilter.closest('.rm_tag_controls').find('.showTagList'); + const hasActiveTags = tagFilter.find('.tag:not(.actionable)').is('.selected, .excluded'); + showTagListButton.toggleClass('indicator', hasActiveTags); } function onTagRemoveClick(event) { @@ -1316,6 +1687,8 @@ export function applyTagsOnGroupSelect(groupId = null) { groupId = groupId ?? (selected_group ? Number(selected_group) : undefined); printTagList($('#groupTagList'), { forEntityOrKey: groupId, tagOptions: { removable: true } }); + printTagFilters(tag_filter_type.group_candidates_list); + printTagFilters(tag_filter_type.group_members_list); } /** @@ -1841,17 +2214,39 @@ function onTagListHintClick() { } $(this).siblings('.innerActionable').toggleClass('hidden'); - power_user.show_tag_filters = $(this).hasClass('selected'); - saveSettingsDebounced(); - console.debug('show_tag_filters', power_user.show_tag_filters); + + // Determine which context this button belongs to and save the setting + let filterType = tag_filter_type.character; + + // Check which section we're in by looking at the sibling header + const $tagControls = $(this).closest('.rm_tag_controls'); + if ($tagControls.prev().is('#rm_group_add_members_header')) { + filterType = tag_filter_type.group_candidates_list; + } else if ($tagControls.prev().is('#rm_group_members_header')) { + filterType = tag_filter_type.group_members_list; + } + + const isSelected = $(this).hasClass('selected'); + setTagFilterVisibility(filterType, isSelected); + console.debug('show_tag_filters for type', filterType, ':', isSelected); } -function onClearAllFiltersClick() { +/** + * Clears all filters for the current list context. + * @param {FilterHelper} filterHelper - The filter helper for the current context + */ +function onClearAllFiltersClick(filterHelper) { console.debug('clear all filters clicked'); + const context = getFilterContext(filterHelper); + if (!context) { + console.warn('Unknown filter helper in onClearAllFiltersClick'); + return; + } + // We have to manually go through the elements and unfilter by clicking... // Thankfully nearly all filter controls are three-state-toggles - const filterTags = $('.rm_tag_controls .rm_tag_filter').find('.tag'); + const filterTags = $(context.selector).find('.tag'); for (const tag of filterTags) { const toggleState = $(tag).attr('data-toggle-state'); if (toggleState !== undefined && !isFilterState(toggleState ?? FILTER_STATES.UNDEFINED, FILTER_STATES.UNDEFINED)) { @@ -1859,8 +2254,8 @@ function onClearAllFiltersClick() { } } - // Reset search too - $('#character_search_bar').val('').trigger('input'); + // Reset search input for this context + $(context.searchInput).val('').trigger('input'); } /** @@ -1891,6 +2286,37 @@ function printViewTagList(tagContainer, empty = true) { } } +function removeMissingTagFilters() { + const tagIds = new Set(tags.map(tag => tag.id)); + + for (const helper of [groupCandidatesFilter, groupMembersFilter, entitiesFilter]) { + const { selected, excluded } = helper.getFilterData(FILTER_TYPES.TAG); + let anyRemoved = false; + + if (Array.isArray(selected)) { + for (let i = selected.length - 1; i >= 0; i--) { + if (!tagIds.has(selected[i])) { + selected.splice(i, 1); + anyRemoved = true; + } + } + } + + if (Array.isArray(excluded)) { + for (let i = excluded.length - 1; i >= 0; i--) { + if (!tagIds.has(excluded[i])) { + excluded.splice(i, 1); + anyRemoved = true; + } + } + } + + if (anyRemoved) { + helper.setFilterData(FILTER_TYPES.TAG, { selected, excluded }); + } + } +} + function registerTagsSlashCommands() { /** * Gets a tag by its name. Optionally can create the tag if it does not exist. @@ -2237,7 +2663,8 @@ function normalizeTagName(name) { .toLowerCase(); } -/** Extracts the character avatar file name from the avatar source URL. +/** + * Extracts the character avatar file name from the avatar source URL. * @param {string} avatarSrc The source URL of the character avatar. * @returns {string|null} The normalized avatar file name, or null if the input is falsy or doesn't contain a valid file name. */ @@ -2270,8 +2697,12 @@ function restoreSavedTagFilters() { if (favState) { ACTIONABLE_TAGS.FAV.filter_state = favState; entitiesFilter.setFilterData(FILTER_TYPES.FAV, favState, true); - groupCandidatesFilter.setFilterData(FILTER_TYPES.FAV, favState, true); } + + // Load persisted filter states for all contexts (including character list) + loadFilterStatesForContext(entitiesFilter, 'CharacterList'); + loadFilterStatesForContext(groupCandidatesFilter, 'GroupCandidates'); + loadFilterStatesForContext(groupMembersFilter, 'GroupMembers'); if (groupState) { ACTIONABLE_TAGS.GROUP.filter_state = groupState; entitiesFilter.setFilterData(FILTER_TYPES.GROUP, groupState, true); @@ -2280,6 +2711,10 @@ function restoreSavedTagFilters() { ACTIONABLE_TAGS.FOLDER.filter_state = folderState; entitiesFilter.setFilterData(FILTER_TYPES.FOLDER, folderState, true); } + + // Note: Regular tag filter states are now loaded from storage via loadFilterStatesForContext() + // The old tag.filter_state property is only maintained for backward compatibility with + // the main character list's actionable tags (Favorites, Groups, Folders) } catch (e) { console.warn('Failed to restore actionable filter states from account storage', e); }