Update mobile app to refresh folder counts when a filter is active (#1970)

This commit is contained in:
Leendert de Borst
2026-04-26 18:55:42 +02:00
committed by Leendert de Borst
parent f2fd267703
commit 28f03ea321
3 changed files with 101 additions and 95 deletions

View File

@@ -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<FlatList<Item | null>>(null);
const [itemsList, setItemsList] = useState<Item[]>([]);
const [allItemsInVault, setAllItemsInVault] = useState<Item[]>([]);
const [folder, setFolder] = useState<Folder | null>(null);
const [subfolders, setSubfolders] = useState<FolderWithCount[]>([]);
const [canCreateSubfolder, setCanCreateSubfolder] = useState(false);
const [allFolders, setAllFolders] = useState<Folder[]>([]);
// 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<FilterType>('all');
const [filterType, setFilterType] = useState<ItemFilterType>('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)

View File

@@ -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<FilterType>('all');
const [filterType, setFilterType] = useState<ItemFilterType>('all');
const [showFilterMenu, setShowFilterMenu] = useState(false);
const [sortOrder, setSortOrder] = useState<CredentialSortOrder>('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))
);

View File

@@ -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;
});
}