From 28f03ea321f21d5cfe3949fb34ee4a9b115ae881 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 26 Apr 2026 18:55:42 +0200 Subject: [PATCH] Update mobile app to refresh folder counts when a filter is active (#1970) --- .../app/(tabs)/items/folder/[id].tsx | 80 ++++++++----------- apps/mobile-app/app/(tabs)/items/index.tsx | 70 ++++++---------- apps/mobile-app/utils/itemFilters.ts | 46 +++++++++++ 3 files changed, 101 insertions(+), 95 deletions(-) create mode 100644 apps/mobile-app/utils/itemFilters.ts diff --git a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx index acce3ef71..a790677d2 100644 --- a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx +++ b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx @@ -14,6 +14,7 @@ import { getFieldValue, FieldKey, ItemTypes } from '@/utils/dist/core/models/vau import emitter from '@/utils/EventEmitter'; import { canHaveSubfolders, getRecursiveItemCount } from '@/utils/folderUtils'; import { HapticsUtility } from '@/utils/HapticsUtility'; +import { applyTypeFilter, isItemTypeFilter, type ItemFilterType } from '@/utils/itemFilters'; import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError'; import { useColors } from '@/hooks/useColorScheme'; @@ -40,18 +41,6 @@ import { useDialog } from '@/context/DialogContext'; import type { FolderWithCount } from '@/components/folders/FolderPill'; -/** - * Filter types for the items list. - */ -type FilterType = 'all' | 'passkeys' | 'attachments' | ItemType; - -/** - * Check if a filter is an item type filter. - */ -const isItemTypeFilter = (filter: FilterType): filter is ItemType => { - return Object.values(ItemTypes).includes(filter as ItemType); -}; - /** * Item type filter option configuration. */ @@ -87,8 +76,8 @@ export default function FolderViewScreen(): React.ReactNode { const flatListRef = useRef>(null); const [itemsList, setItemsList] = useState([]); + const [allItemsInVault, setAllItemsInVault] = useState([]); const [folder, setFolder] = useState(null); - const [subfolders, setSubfolders] = useState([]); const [canCreateSubfolder, setCanCreateSubfolder] = useState(false); const [allFolders, setAllFolders] = useState([]); // No minimum loading delay for folder view since data is already in memory @@ -98,7 +87,7 @@ export default function FolderViewScreen(): React.ReactNode { // Search and filter state (scoped to this folder) const [searchQuery, setSearchQuery] = useState(''); - const [filterType, setFilterType] = useState('all'); + const [filterType, setFilterType] = useState('all'); const [showFilterMenu, setShowFilterMenu] = useState(false); // Sort state @@ -143,29 +132,16 @@ export default function FolderViewScreen(): React.ReactNode { * Filter items by search query and type (within this folder only). */ const filteredItems = useMemo(() => { - return itemsList.filter(item => { - // Apply type filter - let passesTypeFilter = true; + const typeFiltered = applyTypeFilter(itemsList, filterType); - if (filterType === 'passkeys') { - passesTypeFilter = item.HasPasskey === true; - } else if (filterType === 'attachments') { - passesTypeFilter = item.HasAttachment === true; - } else if (isItemTypeFilter(filterType)) { - passesTypeFilter = item.ItemType === filterType; - } + const searchLower = searchQuery.toLowerCase().trim(); + if (!searchLower) { + return typeFiltered; + } - if (!passesTypeFilter) { - return false; - } - - // Apply search filter - const searchLower = searchQuery.toLowerCase().trim(); - - if (!searchLower) { - return true; - } + const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0); + return typeFiltered.filter(item => { const searchableFields = [ item.Name?.toLowerCase() || '', getFieldValue(item, FieldKey.LoginUsername)?.toLowerCase() || '', @@ -174,8 +150,6 @@ export default function FolderViewScreen(): React.ReactNode { getFieldValue(item, FieldKey.NotesContent)?.toLowerCase() || '', ]; - const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0); - return searchWords.every(word => searchableFields.some(field => field.includes(word)) ); @@ -196,8 +170,27 @@ export default function FolderViewScreen(): React.ReactNode { return 0; } - return getRecursiveItemCount(folderId, itemsList, allFolders); - }, [folderId, allFolders, itemsList]); + return getRecursiveItemCount(folderId, allItemsInVault, allFolders); + }, [folderId, allFolders, allItemsInVault]); + + /** + * Direct subfolders of the current folder, with item counts that respect the active + * type/feature filter so each badge matches what the user will see when opening the folder. + */ + const subfolders = useMemo((): FolderWithCount[] => { + if (!folderId || allFolders.length === 0) { + return []; + } + + const itemsForCount = applyTypeFilter(allItemsInVault, filterType); + + const childFolders = allFolders.filter((f: Folder) => f.ParentFolderId === folderId); + return childFolders.map((f) => ({ + id: f.Id, + name: f.Name, + itemCount: getRecursiveItemCount(f.Id, itemsForCount, allFolders), + })); + }, [folderId, allFolders, allItemsInVault, filterType]); /** * Load items in this folder, subfolders, and folder details. @@ -216,21 +209,12 @@ export default function FolderViewScreen(): React.ReactNode { // Filter to only items in this folder const folderItems = items.filter((item: Item) => item.FolderId === folderId); setItemsList(folderItems); + setAllItemsInVault(items); // Find this folder const currentFolder = folders.find((f: Folder) => f.Id === folderId); setFolder(currentFolder || null); - // Get subfolders (direct children only) - const childFolders = folders.filter((f: Folder) => f.ParentFolderId === folderId); - - const subfoldersWithCounts: FolderWithCount[] = childFolders.map((f) => ({ - id: f.Id, - name: f.Name, - itemCount: getRecursiveItemCount(f.Id, items, folders), - })); - - setSubfolders(subfoldersWithCounts); setAllFolders(folders); // Calculate if we can create subfolders (check depth) diff --git a/apps/mobile-app/app/(tabs)/items/index.tsx b/apps/mobile-app/app/(tabs)/items/index.tsx index baffc3e66..a23bb778a 100644 --- a/apps/mobile-app/app/(tabs)/items/index.tsx +++ b/apps/mobile-app/app/(tabs)/items/index.tsx @@ -13,6 +13,7 @@ import type { Item, ItemType } from '@/utils/dist/core/models/vault'; import { getFieldValue, FieldKey, ItemTypes } from '@/utils/dist/core/models/vault'; import emitter from '@/utils/EventEmitter'; import { HapticsUtility } from '@/utils/HapticsUtility'; +import { applyTypeFilter, isItemTypeFilter, type ItemFilterType } from '@/utils/itemFilters'; import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError'; import { useColors } from '@/hooks/useColorScheme'; @@ -38,18 +39,6 @@ import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; import { LocalPreferencesService } from '@/services/LocalPreferencesService'; -/** - * Filter types for the items list. - */ -type FilterType = 'all' | 'passkeys' | 'attachments' | 'totp' | ItemType; - -/** - * Check if a filter is an item type filter. - */ -const isItemTypeFilter = (filter: FilterType): filter is ItemType => { - return Object.values(ItemTypes).includes(filter as ItemType); -}; - /** * Item type filter option configuration. */ @@ -93,7 +82,7 @@ export default function ItemsScreen(): React.ReactNode { // Search and filter state const [searchQuery, setSearchQuery] = useState(''); - const [filterType, setFilterType] = useState('all'); + const [filterType, setFilterType] = useState('all'); const [showFilterMenu, setShowFilterMenu] = useState(false); const [sortOrder, setSortOrder] = useState('OldestFirst'); const [showSortMenu, setShowSortMenu] = useState(false); @@ -174,6 +163,8 @@ export default function ItemsScreen(): React.ReactNode { /** * Get folders with item counts for display. + * Counts respect the active type/feature filter so the folder badge always matches + * the number of items the user will see when navigating into the folder. */ const foldersWithCounts = useMemo((): FolderWithCount[] => { // Don't show folders when searching @@ -181,6 +172,9 @@ export default function ItemsScreen(): React.ReactNode { return []; } + // Apply the active type/feature filter to the items used for counting. + const itemsForCount = applyTypeFilter(itemsList, filterType); + /** * Count items per folder (including items in subfolders recursively). * @param folderId - The folder ID to count items for @@ -188,7 +182,7 @@ export default function ItemsScreen(): React.ReactNode { */ const getRecursiveItemCount = (folderId: string): number => { // Get items directly in this folder - const directItems = itemsList.filter((item: Item) => item.FolderId === folderId); + const directItems = itemsForCount.filter((item: Item) => item.FolderId === folderId); // Get all child folders const childFolders = folders.filter(f => f.ParentFolderId === folderId); @@ -210,7 +204,7 @@ export default function ItemsScreen(): React.ReactNode { itemCount: getRecursiveItemCount(folder.Id) })) .sort((a, b) => a.name.localeCompare(b.name)); - }, [folders, itemsList, searchQuery]); + }, [folders, itemsList, searchQuery, filterType]); /** * Get the title based on the active filter. @@ -248,40 +242,24 @@ export default function ItemsScreen(): React.ReactNode { * Filter items by folder, type, and search query. */ const filteredItems = useMemo(() => { - return itemsList.filter(item => { - /* - * When showing folders (checkbox ON): only show root items (exclude items in folders) - * When not showing folders (checkbox OFF): show all items flat - */ - if (!searchQuery && showFolderItems && item.FolderId) { - return false; - } - // When searching or not showing folders: show all matching items regardless of folder + /* + * When showing folders (checkbox ON): only show root items (exclude items in folders) + * When not showing folders (checkbox OFF): show all items flat + */ + const folderScoped = !searchQuery && showFolderItems + ? itemsList.filter(item => !item.FolderId) + : itemsList; - // Apply type filter - let passesTypeFilter = true; + const typeFiltered = applyTypeFilter(folderScoped, filterType); - if (filterType === 'passkeys') { - passesTypeFilter = item.HasPasskey === true; - } else if (filterType === 'attachments') { - passesTypeFilter = item.HasAttachment === true; - } else if (filterType === 'totp') { - passesTypeFilter = item.HasTotp === true; - } else if (isItemTypeFilter(filterType)) { - passesTypeFilter = item.ItemType === filterType; - } + const searchLower = searchQuery.toLowerCase().trim(); + if (!searchLower) { + return typeFiltered; + } - if (!passesTypeFilter) { - return false; - } - - // Apply search filter - const searchLower = searchQuery.toLowerCase().trim(); - - if (!searchLower) { - return true; - } + const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0); + return typeFiltered.filter(item => { const searchableFields = [ item.Name?.toLowerCase() || '', getFieldValue(item, FieldKey.LoginUsername)?.toLowerCase() || '', @@ -290,8 +268,6 @@ export default function ItemsScreen(): React.ReactNode { getFieldValue(item, FieldKey.NotesContent)?.toLowerCase() || '', ]; - const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0); - return searchWords.every(word => searchableFields.some(field => field.includes(word)) ); diff --git a/apps/mobile-app/utils/itemFilters.ts b/apps/mobile-app/utils/itemFilters.ts new file mode 100644 index 000000000..b8dbf8264 --- /dev/null +++ b/apps/mobile-app/utils/itemFilters.ts @@ -0,0 +1,46 @@ +import type { Item, ItemType } from './dist/core/models/vault'; +import { ItemTypes } from './dist/core/models/vault'; + +/** + * Filter types for the items list. + * - 'all': Show all items + * - 'passkeys': Show only items with passkeys + * - 'attachments': Show only items with attachments + * - 'totp': Show only items with 2FA codes + * - ItemType values: Filter by specific item type (Login, Alias, CreditCard, Note) + */ +export type ItemFilterType = 'all' | 'passkeys' | 'attachments' | 'totp' | ItemType; + +/** + * Check if a filter is an item type filter (Login, Alias, CreditCard, Note). + */ +export function isItemTypeFilter(filter: ItemFilterType): filter is ItemType { + return Object.values(ItemTypes).includes(filter as ItemType); +} + +/** + * Apply the active type/feature filter to a list of items. + * Used both for the visible item list and for computing folder badge counts so they + * stay consistent — when a filter is active, folder counts only include matching items. + */ +export function applyTypeFilter(items: Item[], filterType: ItemFilterType): Item[] { + if (filterType === 'all') { + return items; + } + + return items.filter((item: Item) => { + if (filterType === 'passkeys') { + return item.HasPasskey === true; + } + if (filterType === 'attachments') { + return item.HasAttachment === true; + } + if (filterType === 'totp') { + return item.HasTotp === true; + } + if (isItemTypeFilter(filterType)) { + return item.ItemType === filterType; + } + return true; + }); +}