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>

b418ec5c37cf03c47d1807df54072500c2d91034

Jeff Sandberg <jeff@pdx.su>

Signed
5 files changed, +567 -77Showing whitespace changes
public/css/tags.css+4 -0
@@ -67,6 +67,10 @@
6767 display: none;
6868}
6969
70+.tag.tag-absent {
71+ text-decoration: line-through;
72+}
73+
7074.tag.actionable {
7175 border-radius: 50%;
7276 aspect-ratio: 1 / 1;
public/index.html+7 -1
@@ -6126,6 +6126,12 @@
61266126 </div>
61276127 <div class="inline-drawer-content">
61286128 <div id="currentGroupMembers" name="Current Group Members" class="flex-container flexFlowColumn overflowYAuto flex1">
6129+ <div id="rm_group_members_header">
6130+ <input id="rm_group_members_filter" class="text_pole margin0" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." />
6131+ </div>
6132+ <div class="rm_tag_controls">
6133+ <div class="tags rm_tag_filter"></div>
6134+ </div>
61296135 <div id="rm_group_members_pagination" class="rm_group_members_pagination group_pagination"></div>
61306136 <div id="rm_group_members" class="rm_group_members overflowYAuto flex-container" group_empty_text="Group is empty." data-i18n="[group_empty_text]Group is empty."></div>
61316137 </div>
@@ -6137,7 +6143,7 @@
61376143 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
61386144 </div>
61396145 <div class="inline-drawer-content">
61406146 <div id="unaddedCharList" name="Unadded Char List" class="flex-container flexFlowColumn overflowYAuto flex1">
61416147 <div id="rm_group_add_members_header">
61426148 <input id="rm_group_filter" class="text_pole margin0" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." />
61436149 </div>
public/script.js+2 -1
@@ -953,7 +953,8 @@ export async function printCharacters(fullRefresh = false) {
953953
954954 // We are actually always reprinting filters, as it "doesn't hurt", and this way they are always up to date
955955 printTagFilters(tag_filter_type.character);
956956 printTagFilters(tag_filter_type.group_membergroup_members_list);
957+ printTagFilters(tag_filter_type.group_candidates_list);
957958
958959 // We are also always reprinting the lists on character/group edit window, as these ones doesn't get updated otherwise
959960 applyTagsOnCharacterSelect();
public/scripts/group-chats.js+62 -18
@@ -80,7 +80,7 @@ import {
8080 chatElement,
8181 ensureMessageMediaIsArray,
8282} from '../script.js';
8383import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect, printTagFilters, tag_filter_type } from './tags.js';
8484import { FILTER_TYPES, FilterHelper } from './filters.js';
8585import { isExternalMediaAllowed } from './chats.js';
8686import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
@@ -134,6 +134,7 @@ export const group_generation_mode = {
134134export const DEFAULT_AUTO_MODE_DELAY = 5;
135135
136136export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, debounce_timeout.quick));
137+export const groupMembersFilter = new FilterHelper(debounce(printGroupMembers, debounce_timeout.quick));
137138let autoModeWorker = null;
138139const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), debounce_timeout.relaxed);
139140/** @type {Map<string, number>} */
@@ -1449,6 +1450,10 @@ async function modifyGroupMember(groupId, groupMember, isDelete) {
14491450 printGroupCandidates();
14501451 printGroupMembers();
14511452
1453+ // Refresh the tag filters for both lists to reflect any new tags
1454+ printTagFilters(tag_filter_type.group_candidates_list);
1455+ printTagFilters(tag_filter_type.group_members_list);
1456+
14521457 const groupHasMembers = getGroupCharacters({ doFilter: false, onlyMembers: true }).length > 0;
14531458 $('#rm_group_submit').prop('disabled', !groupHasMembers);
14541459}
@@ -1557,31 +1562,63 @@ function isGroupMember(group, avatarId) {
15571562 * @returns {Array<{item: Character, id: number, type: string}>} Array of group character objects
15581563 */
15591564function getGroupCharacters({ doFilter = false, onlyMembers = false } = {}) {
15601565 function sortMembersFnapplyFilterAndSort(aresults, bfilter, filterSelector) {
1566+ let filtered = results;
1567+ if (doFilter) {
1568+ filtered = filter.applyFilters(filtered);
1569+ }
1570+ const useFilterOrder = doFilter && !!$(filterSelector).val();
1571+ sortEntitiesList(filtered, useFilterOrder, filter);
1572+ filter.clearFuzzySearchCaches();
1573+ return filtered;
1574+ }
1575+
1576+ function handleMembers(results, thisGroup) {
15611577 const membersArray = thisGroup?.members ?? newGroupMembers;
1562- const aIndex = membersArray.indexOf(a.item.avatar);
1578+
1563- const bIndex = membersArray.indexOf(b.item.avatar);
1579+ // Create index map for O(1) lookups in member sort function
1580+ // (separate from characterIndexMap which maps character objects to their array indices)
1581+ const memberIndexMap = new Map(membersArray.map((avatar, index) => [avatar, index]));
1582+
1583+ function sortMembersFn(a, b) {
1584+ const aIndex = memberIndexMap.get(a.item.avatar) ?? -1;
1585+ const bIndex = memberIndexMap.get(b.item.avatar) ?? -1;
15641586 return aIndex - bIndex;
15651587 }
15661588
1567- const thisGroup = openGroupId && groups.find((x) => x.id == openGroupId);
1589+ // Apply manual member sort before filter and sort
15681590 let candidatesfiltered = charactersresults;
1569- .filter((x) => isGroupMember(thisGroup, x.avatar) == onlyMembers)
1570- .map((x) => ({ item: x, id: characters.indexOf(x), type: 'character' }));
1571-
15721591 if (doFilter) {
15731592 candidates filtered = groupCandidatesFiltergroupMembersFilter.applyFilters(candidatesfiltered);
15741593 }
1594+ filtered.sort(sortMembersFn);
15751595
1576- if (onlyMembers) {
1596+ // Apply conditional filter-based sort and cleanup
1577- candidates.sort(sortMembersFn);
1597+ const useFilterOrder = doFilter && !!$('#rm_group_members_filter').val();
1578- } else {
1598+ if (useFilterOrder) {
1579- const useFilterOrder = doFilter && !!$('#rm_group_filter').val();
1599+ sortEntitiesList(filtered, useFilterOrder, groupMembersFilter);
1580- sortEntitiesList(candidates, useFilterOrder, groupCandidatesFilter);
1600+ }
1601+ groupMembersFilter.clearFuzzySearchCaches();
1602+ return filtered;
1603+ }
1604+
1605+ const thisGroup = openGroupId && groups.find((x) => x.id == openGroupId);
1606+
1607+ // Create index map for O(1) lookups when mapping characters to their array indices
1608+ // (separate from memberIndexMap used later for sorting members by their group order)
1609+ const characterIndexMap = new Map(characters.map((char, index) => [char, index]));
1610+
1611+ const results = characters
1612+ .filter((x) => isGroupMember(thisGroup, x.avatar) == onlyMembers)
1613+ .map((x) => ({ item: x, id: characterIndexMap.get(x), type: 'character' }));
1614+
1615+ // Early return for candidates (non-members)
1616+ if (!onlyMembers) {
1617+ return applyFilterAndSort(results, groupCandidatesFilter, '#rm_group_filter');
15811618 }
15821619
1583- groupCandidatesFilter.clearFuzzySearchCaches();
1620+ // Handle members with manual sort capability
15841621 return candidateshandleMembers(results, thisGroup);
15851622}
15861623
15871624function printGroupCandidates() {
@@ -1621,7 +1658,7 @@ function printGroupMembers() {
16211658 const pageSize = Number(accountStorage.getItem(storageKey)) || 5;
16221659 const sizeChangerOptions = [5, 10, 25, 50, 100, 200, 500, 1000];
16231660 $(this).pagination({
16241661 dataSource: getGroupCharacters({ doFilter: falsetrue, onlyMembers: true }),
16251662 pageRange: 1,
16261663 position: 'top',
16271664 showPageNumbers: false,
@@ -1782,6 +1819,7 @@ function select_group_chats(groupId, skipAnimation) {
17821819 $('#group_avatar_preview').empty().append(getGroupAvatar(group));
17831820 $('#rm_group_restore_avatar').toggle(!!group && isValidImageUrl(group.avatar_url));
17841821 $('#rm_group_filter').val('').trigger('input');
1822+ $('#rm_group_members_filter').val('').trigger('input');
17851823 $('#rm_group_activation_strategy').val(replyStrategy);
17861824 $(`#rm_group_activation_strategy option[value="${replyStrategy}"]`).prop('selected', true);
17871825 $('#rm_group_generation_mode').val(generationMode);
@@ -2049,6 +2087,11 @@ function filterGroupMembers() {
20492087 groupCandidatesFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue);
20502088}
20512089
2090+function filterGroupMemberList() {
2091+ const searchValue = String($(this).val()).toLowerCase();
2092+ groupMembersFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue);
2093+}
2094+
20522095async function createGroup() {
20532096 let name = $('#rm_group_chat_name').val().toString();
20542097 let allowSelfResponses = !!$('#rm_group_allow_self_responses').prop('checked');
@@ -2424,6 +2467,7 @@ jQuery(() => {
24242467 openGroupById(groupId);
24252468 });
24262469 $('#rm_group_filter').on('input', filterGroupMembers);
2470+ $('#rm_group_members_filter').on('input', filterGroupMemberList);
24272471 $('#rm_group_submit').on('click', createGroup);
24282472 $('#rm_group_scenario').on('click', setCharacterSettingsOverrides);
24292473 $('#rm_group_automode').on('input', function () {
public/scripts/tags.js+492 -57
@@ -15,7 +15,7 @@ import {
1515} from '../script.js';
1616import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js';
1717
1818import { groupCandidatesFilter, groupMembersFilter, groups, selected_group } from './group-chats.js';
1919import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce, findChar } from './utils.js';
2020import { power_user } from './power-user.js';
2121import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
@@ -53,16 +53,113 @@ export {
5353 removeTagFromMap,
5454};
5555
56-/** @typedef {import('../script.js').Character} Character */
57-
5856const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter';
5957const GROUP_FILTER_SELECTOR = '#rm_group_chats_blockrm_group_add_members_header ~ .rm_tag_controls .rm_tag_filter';
58+const GROUP_MEMBERS_FILTER_SELECTOR = '#rm_group_members_header ~ .rm_tag_controls .rm_tag_filter';
6059const TAG_TEMPLATE = $('#tag_template .tag');
6160const FOLDER_TEMPLATE = $('#bogus_folder_template .bogus_folder_select');
6261const VIEW_TAG_TEMPLATE = $('#tag_view_template .tag_view_item');
6362
63+/**
64+ * Gets the context information (selector and search input) for a filter helper.
65+ * Used to reduce code duplication when working with different filter contexts.
66+ * @param {FilterHelper} filterHelper - The filter helper instance
67+ * @returns {{selector: string, searchInput: string}|null} Context info or null if unknown
68+ */
69+function getFilterContext(filterHelper) {
70+ if (filterHelper === entitiesFilter) {
71+ return {
72+ selector: CHARACTER_FILTER_SELECTOR,
73+ searchInput: '#character_search_bar',
74+ };
75+ } else if (filterHelper === groupCandidatesFilter) {
76+ return {
77+ selector: GROUP_FILTER_SELECTOR,
78+ searchInput: '#rm_group_filter',
79+ };
80+ } else if (filterHelper === groupMembersFilter) {
81+ return {
82+ selector: GROUP_MEMBERS_FILTER_SELECTOR,
83+ searchInput: '#rm_group_members_filter',
84+ };
85+ }
86+ return null;
87+}
88+
89+/**
90+ * Get the filter helper for a given list selector.
91+ * @param {string|JQuery<HTMLElement>} listSelector - jQuery selector for the list
92+ * @returns {FilterHelper} The appropriate filter helper instance
93+ */
6494function getFilterHelper(listSelector) {
65- return $(listSelector).is(GROUP_FILTER_SELECTOR) ? groupCandidatesFilter : entitiesFilter;
95+ const $element = typeof listSelector === 'string' ? $(listSelector) : listSelector;
96+
97+ // Check if this filter is in the group members section
98+ if ($element.closest('#currentGroupMembers').length > 0) {
99+ return groupMembersFilter;
100+ }
101+
102+ // Check if this filter is in the group candidates (add members) section
103+ if ($element.closest('#unaddedCharList').length > 0) {
104+ return groupCandidatesFilter;
105+ }
106+
107+ // Default to character list filter
108+ return entitiesFilter;
109+}
110+
111+/**
112+ * Checks if the given type is a group context.
113+ * @param {tag_filter_type} type - The filter type to check
114+ * @returns {boolean} True if this is a group context
115+ */
116+function isGroupContext(type) {
117+ return [tag_filter_type.group_candidates_list, tag_filter_type.group_members_list].includes(type);
118+}
119+
120+/**
121+ * Gets visible character avatars for a group context.
122+ * @param {tag_filter_type} type - The filter type
123+ * @param {object} currentGroup - The current group object
124+ * @returns {string[]} Array of visible character avatars
125+ */
126+function getVisibleAvatarsForGroupContext(type, currentGroup) {
127+ if (!currentGroup || !Array.isArray(currentGroup.members)) {
128+ return [];
129+ }
130+
131+ switch (type) {
132+ case tag_filter_type.group_members_list:
133+ return currentGroup.members;
134+ case tag_filter_type.group_candidates_list:
135+ return characters
136+ .filter(c => !currentGroup.members.includes(c.avatar))
137+ .map(c => c.avatar);
138+ default:
139+ console.warn('getVisibleAvatarsForGroupContext got invalid type, expected 1 or 2, got ', type);
140+ return [];
141+ }
142+}
143+
144+/**
145+ * Filters actionable tags for group contexts.
146+ * In group contexts, hide GROUP and FOLDER filters but keep Favorites and utility buttons.
147+ * @param {object[]} actionTags - Array of actionable tag objects
148+ * @returns {object[]} Filtered array of actionable tags
149+ */
150+function filterActionableTagsForGroupContext(actionTags) {
151+ return actionTags.filter(tag => {
152+ // Always show Favorites
153+ if (tag.id === ACTIONABLE_TAGS.FAV.id) {
154+ return true;
155+ }
156+ // Hide GROUP and FOLDER filters in group contexts (not relevant)
157+ if (tag.id === ACTIONABLE_TAGS.GROUP.id || tag.id === ACTIONABLE_TAGS.FOLDER.id) {
158+ return false;
159+ }
160+ // Show utility buttons (VIEW, HINT, UNFILTER)
161+ return true;
162+ });
66163}
67164
68165const ACTIONABLE_FILTER_STORAGE_KEYS = Object.freeze({
@@ -71,12 +168,79 @@ const ACTIONABLE_FILTER_STORAGE_KEYS = Object.freeze({
71168 FOLDER: 'TagFilterState_FOLDER',
72169});
73170
171+/**
172+ * Gets the storage key prefix for a filter helper to enable persistence.
173+ * @param {FilterHelper} filterHelper - The filter helper to check
174+ * @returns {string|null} Storage key prefix or null if no persistence
175+ */
176+function getFilterStorageKey(filterHelper) {
177+ if (filterHelper === entitiesFilter) {
178+ return 'CharacterList';
179+ } else if (filterHelper === groupCandidatesFilter) {
180+ return 'GroupCandidates';
181+ } else if (filterHelper === groupMembersFilter) {
182+ return 'GroupMembers';
183+ }
184+ return null;
185+}
186+
187+/**
188+ * Checks if the given filter helper is the main character list filter.
189+ * @param {FilterHelper} filterHelper - The filter helper to check
190+ * @returns {boolean} True if this is the main character list
191+ */
192+function isMainCharacterList(filterHelper) {
193+ return filterHelper === entitiesFilter;
194+}
195+
74196/** @enum {number} */
75197export const tag_filter_type = {
76198 character: 0,
199+ /** @deprecated use `group_candidates_list` instead */
77200 group_member: 1,
201+ group_candidates_list: 1,
202+ group_members_list: 2,
78203};
79204
205+/**
206+ * Gets the power_user setting key for tag filter visibility for a given context.
207+ * @param {number} type - The tag_filter_type
208+ * @returns {string} The power_user setting key
209+ */
210+function getTagFilterVisibilitySetting(type) {
211+ switch (type) {
212+ case tag_filter_type.character:
213+ return 'show_tag_filters';
214+ case tag_filter_type.group_candidates_list:
215+ return 'show_tag_filters_group_candidates';
216+ case tag_filter_type.group_members_list:
217+ return 'show_tag_filters_group_members';
218+ default:
219+ return 'show_tag_filters';
220+ }
221+}
222+
223+/**
224+ * Gets the tag filter visibility state for a given context.
225+ * @param {number} type - The tag_filter_type
226+ * @returns {boolean} Whether tag filters should be shown
227+ */
228+function getTagFilterVisibility(type) {
229+ const settingKey = getTagFilterVisibilitySetting(type);
230+ return power_user[settingKey] ?? false;
231+}
232+
233+/**
234+ * Sets the tag filter visibility state for a given context.
235+ * @param {number} type - The tag_filter_type
236+ * @param {boolean} visible - Whether tag filters should be shown
237+ */
238+function setTagFilterVisibility(type, visible) {
239+ const settingKey = getTagFilterVisibilitySetting(type);
240+ power_user[settingKey] = visible;
241+ saveSettingsDebounced();
242+}
243+
80244/** @enum {number} */
81245export const tag_import_setting = {
82246 ASK: 1,
@@ -93,18 +257,33 @@ export const tag_sort_mode = {
93257};
94258
95259/**
96- * @type {{ FAV: Tag, GROUP: Tag, FOLDER: Tag, VIEW: Tag, HINT: Tag, UNFILTER: Tag }}
260+ * A collection of global actionable tags for the filter panel.
97- * A collection of global actional tags for the filter panel
261+ *
98- * */
262+ * Tags with `filter_state` property (FAV, GROUP, FOLDER) maintain persistent state:
263+ * - Each context (character list, group candidates, group members) saves state independently
264+ * - Main character list also maintains tag.filter_state for backward compatibility
265+ *
266+ * Tags without `filter_state` (VIEW, HINT, UNFILTER) are action buttons only.
267+ */
99268const ACTIONABLE_TAGS = {
100269 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' },
101270 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' },
102271 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' },
103272 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' },
104273 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' },
105274 UNFILTER: { id: '5', sort_order: 6, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' },
106275};
107276
277+/**
278+ * Map of tag IDs to their corresponding filter types.
279+ * Used for actionable tags (Favorites, Groups, Folders).
280+ */
281+const TAG_ID_TO_FILTER_TYPE = new Map([
282+ [ACTIONABLE_TAGS.FAV.id, FILTER_TYPES.FAV],
283+ [ACTIONABLE_TAGS.GROUP.id, FILTER_TYPES.GROUP],
284+ [ACTIONABLE_TAGS.FOLDER.id, FILTER_TYPES.FOLDER],
285+]);
286+
108287/** @type {{[key: string]: Tag}} An optional list of actionables that can be utilized by extensions */
109288const InListActionable = {
110289};
@@ -345,15 +524,68 @@ function getTagBlock(tag, entities, hidden = 0, isUseless = false) {
345524}
346525
347526/**
348- * Applies the favorite filter to the character list.
527+ * Common logic for applying actionable tag filters (Favorites, Groups, Folders).
349- * @param {FilterHelper} _filterHelper Instance of FilterHelper class. Unused since it needs to be applied to both filters.
528+ * Persists state to storage for all filter contexts.
529+ * @param {FilterHelper} filterHelper - Instance of FilterHelper class
530+ * @param {object} tag - The actionable tag object
531+ * @param {string} filterType - The filter type constant
532+ * @param {string} storageKey - The storage key base for persistence
350533 */
351-function filterByFav(_filterHelper) {
534+function applyActionableTagFilter(filterHelper, tag, filterType, storageKey) {
352535 const state = toggleTagThreeState($(this));
353- ACTIONABLE_TAGS.FAV.filter_state = state;
536+
354- accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.FAV, state);
537+ // Persist to storage for all contexts
355- entitiesFilter.setFilterData(FILTER_TYPES.FAV, state);
538+ const storagePrefix = getFilterStorageKey(filterHelper);
356- groupCandidatesFilter.setFilterData(FILTER_TYPES.FAV, state);
539+ if (storagePrefix) {
540+ const contextStorageKey = `${storagePrefix}_${storageKey}`;
541+ accountStorage.setItem(contextStorageKey, state);
542+ }
543+
544+ // Also update global state for main character list (backward compatibility)
545+ if (isMainCharacterList(filterHelper)) {
546+ tag.filter_state = state;
547+ }
548+
549+ // Update the filter helper for the current context
550+ filterHelper.setFilterData(filterType, state);
551+}
552+
553+/**
554+ * Determines the filter state for a tag based on context.
555+ * For actionable tags: reads from persisted state via filter helper.
556+ * For regular tags: reads from the filter helper's TAG filter data.
557+ * @param {FilterHelper} filterHelper - The filter helper for the current context
558+ * @param {object} tag - The tag object
559+ * @param {boolean} isFilterActionable - Whether the tag is an actionable filter tag
560+ * @returns {string} The filter state
561+ */
562+function determineTagFilterState(filterHelper, tag, isFilterActionable) {
563+ if (isFilterActionable) {
564+ // For actionable tags: read from filter helper (which is loaded from storage)
565+ const filterType = TAG_ID_TO_FILTER_TYPE.get(tag.id) || null;
566+ if (filterType) {
567+ return filterHelper.getFilterData(filterType) || DEFAULT_FILTER_STATE;
568+ }
569+ } else {
570+ // For regular tags: read from the filter helper's TAG filter data
571+ const tagFilterData = filterHelper.getFilterData(FILTER_TYPES.TAG);
572+ if (tagFilterData.excluded.includes(tag.id)) {
573+ return 'EXCLUDED';
574+ }
575+ if (tagFilterData.selected.includes(tag.id)) {
576+ return 'SELECTED';
577+ }
578+ }
579+
580+ return DEFAULT_FILTER_STATE;
581+}
582+
583+/**
584+ * Applies the favorite filter to the character list.
585+ * @param {FilterHelper} filterHelper Instance of FilterHelper class.
586+ */
587+function filterByFav(filterHelper) {
588+ applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.FAV, FILTER_TYPES.FAV, ACTIONABLE_FILTER_STORAGE_KEYS.FAV);
357589}
358590
359591/**
@@ -361,10 +593,7 @@ function filterByFav(_filterHelper) {
361593 * @param {FilterHelper} filterHelper Instance of FilterHelper class.
362594 */
363595function filterByGroups(filterHelper) {
364- const state = toggleTagThreeState($(this));
596+ applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.GROUP, FILTER_TYPES.GROUP, ACTIONABLE_FILTER_STORAGE_KEYS.GROUP);
365- ACTIONABLE_TAGS.GROUP.filter_state = state;
366- accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.GROUP, state);
367- filterHelper.setFilterData(FILTER_TYPES.GROUP, state);
368597}
369598
370599/**
@@ -379,10 +608,7 @@ function filterByFolder(filterHelper) {
379608 return;
380609 }
381610
382- const state = toggleTagThreeState($(this));
611+ applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.FOLDER, FILTER_TYPES.FOLDER, ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER);
383- ACTIONABLE_TAGS.FOLDER.filter_state = state;
384- accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER, state);
385- filterHelper.setFilterData(FILTER_TYPES.FOLDER, state);
386612}
387613
388614function loadTagsSettings(settings) {
@@ -942,6 +1168,7 @@ function newTag(tagName) {
9421168 * @property {boolean} [isGeneralList=false] - If true, indicates that this is the general list of tags.
9431169 * @property {boolean} [skipExistsCheck=false] - If true, the tag gets added even if a tag with the same id already exists.
9441170 * @property {boolean} [isCharacterList=false] - If true, indicates that this is the character's list of tags.
1171+ * @property {boolean} [isInactive=false] - If true, indicates that the tag is inactive (for styling purposes).
9451172 */
9461173
9471174/**
@@ -954,6 +1181,7 @@ function newTag(tagName) {
9541181 * @property {function(object): function} [tagActionSelector=undefined] - An optional override for the action property that can be assigned to each tag via tagOptions.
9551182 * 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.
9561183 * @property {TagOptions} [tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList")
1184+ * @property {string[]} [inactiveTags=[]] - List of tag IDs that are considered inactive (for styling purposes).
9571185 */
9581186
9591187/**
@@ -962,7 +1190,7 @@ function newTag(tagName) {
9621190 * @param {JQuery<HTMLElement>|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)
9631191 * @param {PrintTagListOptions} [options] - Optional parameters for printing the tag list.
9641192 */
9651193function printTagList(element, { tags = undefined, addTag = undefined, forEntityOrKey = undefined, empty = true, sort = true, tagActionSelector = undefined, tagOptions = {}, inactiveTags = [] } = {}) {
9661194 const $element = (typeof element === 'string') ? $(element) : element;
9671195 const key = forEntityOrKey !== undefined ? getTagKeyForEntity(forEntityOrKey) : getTagKey();
9681196 let printableTags = tags ? (typeof tags === 'function' ? tags() : tags) : getTagsList(key, sort);
@@ -1018,7 +1246,9 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity
10181246
10191247 // Check if we should print this tag
10201248 if (shouldPrintTag(tag) || additionalTagsPrinted++ < availableSlotsForAdditionalTags) {
1021- appendTagToList($element, tag, tagOptions);
1249+ // Check if this tag is in the inactive list
1250+ const isInactive = inactiveTags.includes(tag.id);
1251+ appendTagToList($element, tag, { ...tagOptions, isInactive });
10221252 } else {
10231253 tagsSkipped++;
10241254 }
@@ -1040,7 +1270,7 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity
10401270
10411271 // Do not bubble further, we are just expanding
10421272 event.stopPropagation();
10431273 printTagList($element, { tags: tags, addTag: addTag, forEntityOrKey: forEntityOrKey, empty: empty, tagActionSelector: tagActionSelector, tagOptions: tagOptions, inactiveTags: inactiveTags });
10441274 };
10451275
10461276 // 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
10611291 * @param {TagOptions} [options={}] - Options for tag behavior
10621292 * @returns {void}
10631293 */
10641294function appendTagToList(listElement, tag, { removable = false, isFilter = false, action = undefined, removeAction = undefined, isGeneralList = false, skipExistsCheck = false, isInactive = false } = {}) {
10651295 if (!listElement) {
10661296 return;
10671297 }
@@ -1097,13 +1327,22 @@ function appendTagToList(listElement, tag, { removable = false, isFilter = false
10971327 tagElement.find('.tag_name').text('').attr('title', `${translate(tag.name)} ${tag.title || ''}`.trim()).addClass(tag.icon);
10981328 tagElement.addClass('actionable');
10991329 }
1330+ if (isInactive) {
1331+ tagElement.addClass('tag-absent');
1332+ }
11001333
11011334 // We could have multiple ways of actions passed in. The manual arguments have precendence in front of a specified tag action
11021335 const clickableAction = action ?? tag.action;
11031336
11041337 // If this is a tag for a general list and its either a filter or actionable, lets mark its current state
11051338 if ((isFilter || clickableAction) && isGeneralList) {
1106- toggleTagThreeState(tagElement, { stateOverride: tag.filter_state ?? DEFAULT_FILTER_STATE });
1339+ const filterHelper = getFilterHelper($(listElement));
1340+ const isFilterActionable = clickableAction && 'filter_state' in tag;
1341+
1342+ if (isFilter || isFilterActionable) {
1343+ const filterState = determineTagFilterState(filterHelper, tag, isFilterActionable);
1344+ toggleTagThreeState(tagElement, { stateOverride: filterState });
1345+ }
11071346 }
11081347
11091348 if (isFilter) {
@@ -1127,16 +1366,77 @@ function onTagFilterClick(listElement) {
11271366
11281367 let state = toggleTagThreeState($(this));
11291368
1130- if (existingTag) {
1369+ const filterHelper = getFilterHelper($(listElement));
1370+
1371+ // Update the tag's filter_state for the main character list (backward compatibility)
1372+ if (existingTag && isMainCharacterList(filterHelper)) {
11311373 existingTag.filter_state = state;
11321374 saveSettingsDebounced();
11331375 }
11341376
1135- // We don't print anything manually, updating the filter will automatically trigger a redraw of all relevant stuff
1377+ // Persist to storage for all contexts
1378+ const storagePrefix = getFilterStorageKey(filterHelper);
1379+ if (storagePrefix && existingTag) {
1380+ const storageKey = `${storagePrefix}_tag_${tagId}`;
1381+ accountStorage.setItem(storageKey, state);
1382+ }
1383+
1384+ // Apply all tag filters by reading from DOM state (this triggers the filter helper update)
11361385 runTagFilters(listElement);
11371386
11381387 // Focus the tag again we were at, if possible. To improve keyboard navigation
11391388 setTimeout(() => parent.find(`.tag[id="${tagId}"]`).trigger('focus'), DEFAULT_PRINT_TIMEOUT + 1);
1389+
1390+ updateTagFilterIndicator(listElement);
1391+}
1392+
1393+/**
1394+ * Loads persisted filter states for a given filter context.
1395+ * @param {FilterHelper} filterHelper - The filter helper instance
1396+ * @param {string} storagePrefix - The storage key prefix for this context
1397+ */
1398+function loadFilterStatesForContext(filterHelper, storagePrefix) {
1399+ const validStates = new Set(Object.keys(FILTER_STATES));
1400+ const readState = (/** @type {string} */ storageKey) => {
1401+ const v = accountStorage.getItem(storageKey);
1402+ return v && validStates.has(v) ? v : null;
1403+ };
1404+
1405+ // Load actionable tag states (Favorites, Groups, Folders)
1406+ const favState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.FAV}`);
1407+ if (favState) {
1408+ filterHelper.setFilterData(FILTER_TYPES.FAV, favState, true);
1409+ }
1410+
1411+ const groupState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.GROUP}`);
1412+ if (groupState) {
1413+ filterHelper.setFilterData(FILTER_TYPES.GROUP, groupState, true);
1414+ }
1415+
1416+ const folderState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER}`);
1417+ if (folderState) {
1418+ filterHelper.setFilterData(FILTER_TYPES.FOLDER, folderState, true);
1419+ }
1420+
1421+ // Load regular tag filter states
1422+ const tagFilterData = filterHelper.getFilterData(FILTER_TYPES.TAG);
1423+ for (const tag of tags) {
1424+ const storageKey = `${storagePrefix}_tag_${tag.id}`;
1425+ const state = readState(storageKey);
1426+
1427+ if (state) {
1428+ if (state === 'SELECTED') {
1429+ if (!tagFilterData.selected.includes(tag.id)) {
1430+ tagFilterData.selected.push(tag.id);
1431+ }
1432+ } else if (state === 'EXCLUDED') {
1433+ if (!tagFilterData.excluded.includes(tag.id)) {
1434+ tagFilterData.excluded.push(tag.id);
1435+ }
1436+ }
1437+ }
1438+ }
1439+ filterHelper.setFilterData(FILTER_TYPES.TAG, tagFilterData, true);
11401440}
11411441
11421442/**
@@ -1201,20 +1501,77 @@ function runTagFilters(listElement) {
12011501}
12021502
12031503function printTagFilters(type = tag_filter_type.character) {
1204- const FILTER_SELECTOR = type === tag_filter_type.character ? CHARACTER_FILTER_SELECTOR : GROUP_FILTER_SELECTOR;
1504+ removeMissingTagFilters();
1505+
1506+ let FILTER_SELECTOR;
1507+ switch (type) {
1508+ case tag_filter_type.character:
1509+ FILTER_SELECTOR = CHARACTER_FILTER_SELECTOR;
1510+ break;
1511+ case tag_filter_type.group_candidates_list:
1512+ FILTER_SELECTOR = GROUP_FILTER_SELECTOR;
1513+ break;
1514+ case tag_filter_type.group_members_list:
1515+ FILTER_SELECTOR = GROUP_MEMBERS_FILTER_SELECTOR;
1516+ break;
1517+ default:
1518+ FILTER_SELECTOR = CHARACTER_FILTER_SELECTOR;
1519+ break;
1520+ }
1521+
12051522 $(FILTER_SELECTOR).empty();
12061523
12071524 // Print all action tags. (Rework 'Folder' button to some kind of onboarding if no folders are enabled yet)
12081525 constlet actionTags = Object.values(ACTIONABLE_TAGS);
12091526 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';
1527+
1528+ // For group contexts, filter actionable tags to only show relevant ones
1529+ if (isGroupContext(type)) {
1530+ actionTags = filterActionableTagsForGroupContext(actionTags);
1531+ }
1532+
12101533 printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: actionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
12111534
12121535 const inListActionTags = Object.values(InListActionable);
12131536 printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: inListActionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
12141537
1538+ // Determine which character tags to display based on context
1539+ let tagsToDisplay;
1540+ let inactiveTags = [];
1541+
1542+ if (isGroupContext(type)) {
1543+ // For group contexts, show all tags but mark ones without presence in current context as inactive
1544+ // CAUTION: when called by openGroupById, the selected_group variable might not yet be updated
1545+ const currentGroup = selected_group ? groups.find(x => x.id == selected_group) : null;
1546+ const visibleAvatars = getVisibleAvatarsForGroupContext(type, currentGroup);
1547+
1548+ if (visibleAvatars.length > 0) {
1549+ // Get tags that are assigned to at least one visible character
1550+ const activeCharacterTagIds = visibleAvatars
1551+ .map(avatar => tag_map[avatar] || [])
1552+ .flat()
1553+ .filter(onlyUnique);
1554+
1555+ // Show all tags that exist in the tag_map
1556+ const allCharacterTagIds = Object.values(tag_map).flat().filter(onlyUnique);
1557+ tagsToDisplay = tags.filter(x => allCharacterTagIds.includes(x.id)).sort(compareTagsForSort);
1558+
1559+ // Mark tags that are not in the active set as inactive
1560+ inactiveTags = tagsToDisplay
1561+ .filter(x => !activeCharacterTagIds.includes(x.id))
1562+ .map(x => x.id);
1563+ } else {
1564+ // No group selected, show no tags
1565+ tagsToDisplay = [];
1566+ }
1567+ } else {
1568+ // For main character list, show all tags as before
12151569 const characterTagIds = Object.values(tag_map).flat();
12161570 const tagsToDisplay = tags.filter(x => characterTagIds.includes(x.id)).sort(compareTagsForSort);
1217- printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { isFilter: true, isGeneralList: true } });
1571+ }
1572+
1573+ printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { isFilter: true, isGeneralList: true }, inactiveTags: inactiveTags });
1574+
12181575
12191576 // Print bogus folder navigation
12201577 const bogusDrilldown = $(FILTER_SELECTOR).siblings('.rm_tag_bogus_drilldown');
@@ -1224,22 +1581,36 @@ function printTagFilters(type = tag_filter_type.character) {
12241581 printTagList(bogusDrilldown, { tags: navigatedTags, tagOptions: { removable: true } });
12251582 }
12261583
1227- runTagFilters(FILTER_SELECTOR);
1584+ // Don't call runTagFilters here - it would overwrite the loaded filter states with the DOM state.
1585+ // The visual state (CSS classes) already matches the filter helper state set by loadFilterStatesForContext.
1586+ // runTagFilters is only needed when user clicks a tag (handled in onTagFilterClick).
12281587
1229- if (power_user.show_tag_filters) {
1588+ // Initialize the tag list visibility based on saved settings for this context
1230- $('.rm_tag_controls .showTagList').addClass('selected');
1589+ const shouldShowTags = getTagFilterVisibility(type);
12311590 const showTagListButton = $(FILTER_SELECTOR).closest('.rm_tag_controls').find('.tag:not(.actionable)showTagList').show();
1232- }
12331591
1234- updateTagFilterIndicator();
1592+ // Update button state to match the saved setting
1235-}
1593+ showTagListButton.toggleClass('selected', shouldShowTags);
12361594
12371595function if updateTagFilterIndicator(shouldShowTags) {
12381596 if ( $('.rm_tag_controls'FILTER_SELECTOR).find('.tag:not(.actionable)').isshow('.selected, .excluded')) {;
1239- $('.rm_tag_controls .showTagList').addClass('indicator');
12401597 } else {
12411598 $(FILTER_SELECTOR).find('.rm_tag_controls tag:not(.showTagListactionable)').removeClasshide('indicator');
1599+ }
1600+
1601+ updateTagFilterIndicator(FILTER_SELECTOR);
12421602}
1603+
1604+/**
1605+ * Updates the tag filter indicator based on the selected/excluded tags in the given filter selector
1606+ * @param {string|JQuery<HTMLElement>} filterSelector - The selector or jQuery element for the tag filter container
1607+ */
1608+function updateTagFilterIndicator(filterSelector) {
1609+ const selector = filterSelector || CHARACTER_FILTER_SELECTOR;
1610+ const tagFilter = typeof selector === 'string' ? $(selector) : selector;
1611+ const showTagListButton = tagFilter.closest('.rm_tag_controls').find('.showTagList');
1612+ const hasActiveTags = tagFilter.find('.tag:not(.actionable)').is('.selected, .excluded');
1613+ showTagListButton.toggleClass('indicator', hasActiveTags);
12431614}
12441615
12451616function onTagRemoveClick(event) {
@@ -1316,6 +1687,8 @@ export function applyTagsOnGroupSelect(groupId = null) {
13161687
13171688 groupId = groupId ?? (selected_group ? Number(selected_group) : undefined);
13181689 printTagList($('#groupTagList'), { forEntityOrKey: groupId, tagOptions: { removable: true } });
1690+ printTagFilters(tag_filter_type.group_candidates_list);
1691+ printTagFilters(tag_filter_type.group_members_list);
13191692}
13201693
13211694/**
@@ -1841,17 +2214,39 @@ function onTagListHintClick() {
18412214 }
18422215
18432216 $(this).siblings('.innerActionable').toggleClass('hidden');
1844- power_user.show_tag_filters = $(this).hasClass('selected');
2217+
1845- saveSettingsDebounced();
2218+ // Determine which context this button belongs to and save the setting
1846- console.debug('show_tag_filters', power_user.show_tag_filters);
2219+ let filterType = tag_filter_type.character;
2220+
2221+ // Check which section we're in by looking at the sibling header
2222+ const $tagControls = $(this).closest('.rm_tag_controls');
2223+ if ($tagControls.prev().is('#rm_group_add_members_header')) {
2224+ filterType = tag_filter_type.group_candidates_list;
2225+ } else if ($tagControls.prev().is('#rm_group_members_header')) {
2226+ filterType = tag_filter_type.group_members_list;
2227+ }
2228+
2229+ const isSelected = $(this).hasClass('selected');
2230+ setTagFilterVisibility(filterType, isSelected);
2231+ console.debug('show_tag_filters for type', filterType, ':', isSelected);
18472232}
18482233
1849-function onClearAllFiltersClick() {
2234+/**
2235+ * Clears all filters for the current list context.
2236+ * @param {FilterHelper} filterHelper - The filter helper for the current context
2237+ */
2238+function onClearAllFiltersClick(filterHelper) {
18502239 console.debug('clear all filters clicked');
18512240
2241+ const context = getFilterContext(filterHelper);
2242+ if (!context) {
2243+ console.warn('Unknown filter helper in onClearAllFiltersClick');
2244+ return;
2245+ }
2246+
18522247 // We have to manually go through the elements and unfilter by clicking...
18532248 // Thankfully nearly all filter controls are three-state-toggles
18542249 const filterTags = $('.rm_tag_controls context.rm_tag_filter'selector).find('.tag');
18552250 for (const tag of filterTags) {
18562251 const toggleState = $(tag).attr('data-toggle-state');
18572252 if (toggleState !== undefined && !isFilterState(toggleState ?? FILTER_STATES.UNDEFINED, FILTER_STATES.UNDEFINED)) {
@@ -1859,8 +2254,8 @@ function onClearAllFiltersClick() {
18592254 }
18602255 }
18612256
18622257 // Reset search tooinput for this context
18632258 $('#character_search_bar'context.searchInput).val('').trigger('input');
18642259}
18652260
18662261/**
@@ -1891,6 +2286,37 @@ function printViewTagList(tagContainer, empty = true) {
18912286 }
18922287}
18932288
2289+function removeMissingTagFilters() {
2290+ const tagIds = new Set(tags.map(tag => tag.id));
2291+
2292+ for (const helper of [groupCandidatesFilter, groupMembersFilter, entitiesFilter]) {
2293+ const { selected, excluded } = helper.getFilterData(FILTER_TYPES.TAG);
2294+ let anyRemoved = false;
2295+
2296+ if (Array.isArray(selected)) {
2297+ for (let i = selected.length - 1; i >= 0; i--) {
2298+ if (!tagIds.has(selected[i])) {
2299+ selected.splice(i, 1);
2300+ anyRemoved = true;
2301+ }
2302+ }
2303+ }
2304+
2305+ if (Array.isArray(excluded)) {
2306+ for (let i = excluded.length - 1; i >= 0; i--) {
2307+ if (!tagIds.has(excluded[i])) {
2308+ excluded.splice(i, 1);
2309+ anyRemoved = true;
2310+ }
2311+ }
2312+ }
2313+
2314+ if (anyRemoved) {
2315+ helper.setFilterData(FILTER_TYPES.TAG, { selected, excluded });
2316+ }
2317+ }
2318+}
2319+
18942320function registerTagsSlashCommands() {
18952321 /**
18962322 * Gets a tag by its name. Optionally can create the tag if it does not exist.
@@ -2237,7 +2663,8 @@ function normalizeTagName(name) {
22372663 .toLowerCase();
22382664}
22392665
2240-/** Extracts the character avatar file name from the avatar source URL.
2666+/**
2667+ * Extracts the character avatar file name from the avatar source URL.
22412668 * @param {string} avatarSrc The source URL of the character avatar.
22422669 * @returns {string|null} The normalized avatar file name, or null if the input is falsy or doesn't contain a valid file name.
22432670 */
@@ -2270,8 +2697,12 @@ function restoreSavedTagFilters() {
22702697 if (favState) {
22712698 ACTIONABLE_TAGS.FAV.filter_state = favState;
22722699 entitiesFilter.setFilterData(FILTER_TYPES.FAV, favState, true);
2273- groupCandidatesFilter.setFilterData(FILTER_TYPES.FAV, favState, true);
22742700 }
2701+
2702+ // Load persisted filter states for all contexts (including character list)
2703+ loadFilterStatesForContext(entitiesFilter, 'CharacterList');
2704+ loadFilterStatesForContext(groupCandidatesFilter, 'GroupCandidates');
2705+ loadFilterStatesForContext(groupMembersFilter, 'GroupMembers');
22752706 if (groupState) {
22762707 ACTIONABLE_TAGS.GROUP.filter_state = groupState;
22772708 entitiesFilter.setFilterData(FILTER_TYPES.GROUP, groupState, true);
@@ -2280,6 +2711,10 @@ function restoreSavedTagFilters() {
22802711 ACTIONABLE_TAGS.FOLDER.filter_state = folderState;
22812712 entitiesFilter.setFilterData(FILTER_TYPES.FOLDER, folderState, true);
22822713 }
2714+
2715+ // Note: Regular tag filter states are now loaded from storage via loadFilterStatesForContext()
2716+ // The old tag.filter_state property is only maintained for backward compatibility with
2717+ // the main character list's actionable tags (Favorites, Groups, Folders)
22832718 } catch (e) {
22842719 console.warn('Failed to restore actionable filter states from account storage', e);
22852720 }