diff --git a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx index 556fb72f3..9408bb232 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx @@ -24,7 +24,7 @@ import type { Folder } from '@/utils/db/repositories/FolderRepository'; import type { CredentialSortOrder } from '@/utils/db/repositories/SettingsRepository'; import type { Item } from '@/utils/dist/core/models/vault'; import { canHaveSubfolders, getDescendantFolderIds, getFolderPath, getRecursiveItemCount } from '@/utils/folderUtils'; -import { applyTypeFilter, isItemTypeFilter, type ItemFilterType } from '@/utils/itemFilters'; +import { applyTypeFilter, isItemTypeFilter, parseItemFilterType, type ItemFilterType } from '@/utils/itemFilters'; import { LocalPreferencesService } from '@/utils/LocalPreferencesService'; import { useMinDurationLoading } from '@/hooks/useMinDurationLoading'; @@ -108,7 +108,11 @@ const ItemsList: React.FC = () => { const [searchTerm, setSearchTerm] = useState(() => { return searchParams.get('search') || ''; }); - const [filterType, setItemFilterType] = useState(getStoredFilter()); + // URL filter takes precedence so navigating between folders preserves the active filter. + const [filterType, setItemFilterType] = useState(() => { + const urlFilter = searchParams.get('filter'); + return urlFilter ? parseItemFilterType(urlFilter) : getStoredFilter(); + }); const [showFilterMenu, setShowFilterMenu] = useState(false); const [showFolderModal, setShowFolderModal] = useState(false); const [showDeleteFolderModal, setShowDeleteFolderModal] = useState(false); @@ -157,10 +161,19 @@ const ItemsList: React.FC = () => { /** * Update URL when search term changes to persist it in navigation history. + * Preserves any existing filter param in the URL (set by folder navigation) — the + * filter is initialized from the URL on mount and otherwise managed in state, so + * we just leave whatever filter param is there alone. */ useEffect(() => { - // Build the new URL with or without search param - const newUrl = searchTerm ? `${location.pathname}?search=${encodeURIComponent(searchTerm)}` : location.pathname; + const params = new URLSearchParams(location.search); + if (searchTerm) { + params.set('search', searchTerm); + } else { + params.delete('search'); + } + const queryString = params.toString(); + const newUrl = queryString ? `${location.pathname}?${queryString}` : location.pathname; // Only update if the URL actually changed const currentUrl = location.pathname + location.search; @@ -434,13 +447,11 @@ const ItemsList: React.FC = () => { }, [dbContext?.sqliteClient, setIsLoading, setIsInitialLoading]); /** - * Get the title based on the active filter and current folder + * Get the title based on the active filter and current folder. + * An active filter takes precedence over the folder name so the title matches the + * filter that's actually being applied — same behavior as the root view. */ const getFilterTitle = () : string => { - if (currentFolderId && currentFolderName) { - return currentFolderName; - } - switch (filterType) { case 'passkeys': return t('items.filters.passkeys'); @@ -449,6 +460,9 @@ const ItemsList: React.FC = () => { case 'totp': return t('items.filters.totp'); case 'all': + if (currentFolderId && currentFolderName) { + return currentFolderName; + } return t('items.title'); default: // Check if it's an item type filter @@ -458,17 +472,21 @@ const ItemsList: React.FC = () => { return t(itemTypeOption.titleKey); } } + if (currentFolderId && currentFolderName) { + return currentFolderName; + } return t('items.title'); } }; /** - * Navigate into a folder via URL + * Navigate into a folder via URL, preserving the active filter so the folder view + * shows the same subset reflected in the folder pill's badge count. */ const handleFolderClick = useCallback((folderId: string, _folderName: string) => { setSearchTerm(''); // Clear search when entering folder - navigate(`/items/folder/${folderId}`); - }, [navigate]); + navigate(`/items/folder/${folderId}?filter=${encodeURIComponent(filterType)}`); + }, [navigate, filterType]); /** * Get folders with item counts. diff --git a/apps/browser-extension/src/utils/itemFilters.ts b/apps/browser-extension/src/utils/itemFilters.ts index aaee068c8..883ff1b5f 100644 --- a/apps/browser-extension/src/utils/itemFilters.ts +++ b/apps/browser-extension/src/utils/itemFilters.ts @@ -18,6 +18,23 @@ export function isItemTypeFilter(filter: ItemFilterType): filter is ItemType { return Object.values(ItemTypes).includes(filter as ItemType); } +/** + * Parse a filter value from a URL/route param. Returns 'all' if the value is missing + * or doesn't match a known filter, so an unexpected param can't break the screen. + */ +export function parseItemFilterType(value: string | null | undefined): ItemFilterType { + if (!value) { + return 'all'; + } + if (value === 'all' || value === 'passkeys' || value === 'attachments' || value === 'totp') { + return value; + } + if (isItemTypeFilter(value as ItemFilterType)) { + return value as ItemType; + } + return 'all'; +} + /** * 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 diff --git a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx index a790677d2..e446ad7f9 100644 --- a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx +++ b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx @@ -14,7 +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 { applyTypeFilter, isItemTypeFilter, parseItemFilterType, type ItemFilterType } from '@/utils/itemFilters'; import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError'; import { useColors } from '@/hooks/useColorScheme'; @@ -65,7 +65,7 @@ const ITEM_TYPE_OPTIONS: ItemTypeOption[] = [ * Simplified view with search scoped to this folder only. */ export default function FolderViewScreen(): React.ReactNode { - const { id: folderId } = useLocalSearchParams<{ id: string }>(); + const { id: folderId, filter: filterParam } = useLocalSearchParams<{ id: string; filter?: string }>(); const { syncVault } = useVaultSync(); const colors = useColors(); const { t } = useTranslation(); @@ -85,9 +85,9 @@ export default function FolderViewScreen(): React.ReactNode { const [refreshing, setRefreshing] = useMinDurationLoading(false, 200); const { executeVaultMutation } = useVaultMutate(); - // Search and filter state (scoped to this folder) + // Initial filter comes from the route param so navigating from a filtered list into a folder keeps the filter active. const [searchQuery, setSearchQuery] = useState(''); - const [filterType, setFilterType] = useState('all'); + const [filterType, setFilterType] = useState(() => parseItemFilterType(filterParam)); const [showFilterMenu, setShowFilterMenu] = useState(false); // Sort state @@ -428,11 +428,14 @@ export default function FolderViewScreen(): React.ReactNode { }, [folderId, router, navigate]); /** - * Handle subfolder click - navigate to subfolder view. + * Handle subfolder click - navigate to subfolder view, preserving the active filter + * so the subfolder opens with the same filter that produced its badge count. */ const handleSubfolderClick = useCallback((subfolderId: string) => { - navigate(() => router.push(`/(tabs)/items/folder/${subfolderId}`)); - }, [router, navigate]); + navigate(() => { + router.push(`/(tabs)/items/folder/${subfolderId}?filter=${encodeURIComponent(filterType)}` as '/(tabs)/items/folder/[id]'); + }); + }, [router, navigate, filterType]); /** * Create a new subfolder. diff --git a/apps/mobile-app/app/(tabs)/items/index.tsx b/apps/mobile-app/app/(tabs)/items/index.tsx index a23bb778a..7c2660b3d 100644 --- a/apps/mobile-app/app/(tabs)/items/index.tsx +++ b/apps/mobile-app/app/(tabs)/items/index.tsx @@ -504,11 +504,14 @@ export default function ItemsScreen(): React.ReactNode { }, [dbContext.sqliteClient, executeVaultMutation, loadItems]); /** - * Navigate to a folder. + * Navigate to a folder, preserving the active type/feature filter so the folder view + * shows the same subset of items reflected in the folder's badge count. */ const handleFolderClick = useCallback((folderId: string) => { - navigate(() => router.push(`/(tabs)/items/folder/${folderId}`)); - }, [router, navigate]); + navigate(() => { + router.push(`/(tabs)/items/folder/${folderId}?filter=${encodeURIComponent(filterType)}` as '/(tabs)/items/folder/[id]'); + }); + }, [router, navigate, filterType]); /** * Create a new folder. diff --git a/apps/mobile-app/utils/itemFilters.ts b/apps/mobile-app/utils/itemFilters.ts index b8dbf8264..584c3c14b 100644 --- a/apps/mobile-app/utils/itemFilters.ts +++ b/apps/mobile-app/utils/itemFilters.ts @@ -18,6 +18,23 @@ export function isItemTypeFilter(filter: ItemFilterType): filter is ItemType { return Object.values(ItemTypes).includes(filter as ItemType); } +/** + * Parse a filter value from a URL/route param. Returns 'all' if the value is missing + * or doesn't match a known filter, so an unexpected param can't break the screen. + */ +export function parseItemFilterType(value: string | null | undefined): ItemFilterType { + if (!value) { + return 'all'; + } + if (value === 'all' || value === 'passkeys' || value === 'attachments' || value === 'totp') { + return value; + } + if (isItemTypeFilter(value as ItemFilterType)) { + return value as ItemType; + } + return 'all'; +} + /** * 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