From cad9296a8451fa823dd1c981506c21744629746d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:28:20 +0100 Subject: [PATCH] feat: list starred folders and recent files at the top of files --- src/lib/__tests__/files-places.test.ts | 117 ++++++++++++ src/lib/files-places.ts | 111 ++++++++++++ src/screens/FilesScreen.tsx | 236 +++++++++++++++++++++++-- src/stores/files-places-store.ts | 68 +++++++ 4 files changed, 521 insertions(+), 11 deletions(-) create mode 100644 src/lib/__tests__/files-places.test.ts create mode 100644 src/lib/files-places.ts create mode 100644 src/stores/files-places-store.ts diff --git a/src/lib/__tests__/files-places.test.ts b/src/lib/__tests__/files-places.test.ts new file mode 100644 index 00000000..3374c483 --- /dev/null +++ b/src/lib/__tests__/files-places.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { FileNode } from '../../api/types'; +import { + RECENT_FILES_MAX, + addRecentFile, + dropFavoritesUnder, + folderPathOf, + moveFavorites, + nodePlace, + pruneRecentFiles, + resolveFolderPath, + toggleFavorite, +} from '../files-places'; +import { NO_FILES_PLACES, useFilesPlacesStore } from '../../stores/files-places-store'; + +// The Files tab had no starred folders or recent files (file 10 "starred and +// recent"). Webmail stores/file-store.ts favorites / recentFiles, +// navigateByPath and revealNode. + +const folder = (id: string, name: string, parentId: string | null = null, extra: Partial = {}) => + ({ id, name, parentId, blobId: null, ...extra }) as FileNode; +const file = (id: string, name: string, parentId: string | null = null, extra: Partial = {}) => + ({ id, name, parentId, blobId: `b-${id}`, ...extra }) as FileNode; + +const nodes: FileNode[] = [ + folder('d1', 'Docs'), + folder('d2', 'Sub', 'd1'), + file('f1', 'a.pdf', 'd2'), + file('f0', 'top.txt'), + // A drive shared with the user: the owner's folder above the shared one + // is a placeholder (Stalwart 1.0). + folder('o:top', 'Owner top', null, { isShared: true, discoverableOnly: true }), + folder('o:s', 'Docs', 'o:top', { isShared: true }), + file('o:f', 'shared.txt', 'o:s', { isShared: true }), +]; + +describe('files places', () => { + it('walks a path from the root, the own folder first', () => { + expect(resolveFolderPath(nodes, '/Docs/Sub')).toEqual([{ id: 'd1', name: 'Docs' }, { id: 'd2', name: 'Sub' }]); + expect(resolveFolderPath(nodes, '/')).toEqual([]); + expect(resolveFolderPath(nodes, '/Docs/Gone')).toBeNull(); + // A file is not a folder to stand in. + expect(resolveFolderPath(nodes, '/top.txt')).toBeNull(); + expect(folderPathOf([{ id: 'd1', name: 'Docs' }, { id: 'd2', name: 'Sub' }])).toBe('/Docs/Sub'); + }); + + it('finds where a node is listed, a shared one under its shared root', () => { + expect(nodePlace(nodes, 'f1')?.stack).toEqual([{ id: 'd1', name: 'Docs' }, { id: 'd2', name: 'Sub' }]); + expect(nodePlace(nodes, 'f0')?.stack).toEqual([]); + expect(nodePlace(nodes, 'o:f')?.stack).toEqual([{ id: 'o:s', name: 'Docs' }]); + expect(nodePlace(nodes, 'o:top')).toBeNull(); + expect(nodePlace(nodes, 'gone')).toBeNull(); + }); + + it('keeps stars by path, following a rename and dropping a delete', () => { + expect(toggleFavorite(['/A'], '/B')).toEqual(['/A', '/B']); + expect(toggleFavorite(['/A', '/B'], '/A')).toEqual(['/B']); + expect(moveFavorites(['/Docs', '/Docs/Sub', '/Docsx'], '/Docs', '/Papers')).toEqual(['/Papers', '/Papers/Sub', '/Docsx']); + expect(dropFavoritesUnder(['/Docs', '/Docs/Sub', '/Docsx'], '/Docs')).toEqual(['/Docsx']); + }); + + it('keeps the newest 20 recent files, once each, and prunes the gone', () => { + let recent = addRecentFile([], 'a', '1', 1); + recent = addRecentFile(recent, 'b', '2', 2); + recent = addRecentFile(recent, 'a', '1', 3); + expect(recent.map((r) => r.id)).toEqual(['1', '2']); + for (let i = 0; i < 30; i++) recent = addRecentFile(recent, `n${i}`, `x${i}`, 10 + i); + expect(recent).toHaveLength(RECENT_FILES_MAX); + expect(recent[0].id).toBe('x29'); + expect(pruneRecentFiles(recent, new Set(['x29', 'x28'])).map((r) => r.id)).toEqual(['x29', 'x28']); + }); +}); + +describe('files places store', () => { + beforeEach(() => useFilesPlacesStore.setState({ places: {} })); + + it('keeps each account to itself', () => { + const s = useFilesPlacesStore.getState(); + s.toggleFavorite('alice@a', '/Docs'); + s.addRecentFile('bob@b', 'x.pdf', 'f1'); + expect(useFilesPlacesStore.getState().places['alice@a']).toEqual({ favorites: ['/Docs'], recent: [] }); + expect(useFilesPlacesStore.getState().places['bob@b'].recent.map((r) => r.id)).toEqual(['f1']); + // A listing of bob's drive leaves alice's alone. + s.pruneRecentFiles('bob@b', new Set()); + expect(useFilesPlacesStore.getState().places['bob@b'].recent).toEqual([]); + expect(useFilesPlacesStore.getState().places['alice@a'].favorites).toEqual(['/Docs']); + }); + + it('changes nothing for a change that changes nothing', () => { + const before = useFilesPlacesStore.getState().places; + useFilesPlacesStore.getState().pruneRecentFiles('alice@a', new Set()); + useFilesPlacesStore.getState().toggleFavorite('', '/Docs'); + expect(useFilesPlacesStore.getState().places).toBe(before); + expect(NO_FILES_PLACES).toEqual({ favorites: [], recent: [] }); + }); + + // No RN renderer in the test env: check the screen's wiring instead. + it('is fed and shown by the Files tab', () => { + const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/FilesScreen.tsx'), 'utf8'); + // Opening, opening with and downloading a file make it recent. + expect(screen).toMatch(/const openPreview = useCallback\(\(row: FileRow\) => \{\s*setPreviewTarget\(row\);\s*recordRecent\(row\);/); + expect(screen).toMatch(/if \(!row\.blobId\) return;\s*recordRecent\(row\);/); + expect(screen).toMatch(/await downloadAttachment\([^)]*\);\s*recordRecent\(row\);/); + // A listing prunes; a rename, move or delete of a folder updates the stars. + expect(screen).toMatch(/pruneRecentFiles\(account, new Set\(withoutTrash\(nodes\)\.map\(\(n\) => n\.id\)\)\)/); + expect(screen).toMatch(/moveFavorites\(account, oldPath, oldPath\.slice\(0, oldPath\.lastIndexOf\('\/'\) \+ 1\) \+ name\)/); + expect(screen).toMatch(/dropStarsOf\(done\.map/); + expect(screen).toMatch(/dropStarsOf\(folderPaths\);/); + // The root lists them above its files, in both views. + expect(screen.match(/ListHeaderComponent=\{placesHeader\}/g)).toHaveLength(2); + expect(screen).toMatch(/const showPlaces = path\.length === 0 && !inTrash && !searchQuery\.trim\(\);/); + // A folder's actions star it. + expect(screen).toMatch(/starred \? t\('files\.unstar', 'Remove star'\) : t\('files\.star', 'Star'\)/); + }); +}); diff --git a/src/lib/files-places.ts b/src/lib/files-places.ts new file mode 100644 index 00000000..5f960129 --- /dev/null +++ b/src/lib/files-places.ts @@ -0,0 +1,111 @@ +// Starred folders and recent files of the Files tab, and the walks that open +// a folder by its path or a node where it is listed. Port of the webmail's +// file-store favorites/recentFiles (kept by path and by id, at most 20 +// recent files, 8 of them shown) and navigateByPath/revealNode. +import type { FileNode } from '../api/types'; +import { isFolder, listFolder } from '../api/files'; + +/** One folder of the stack the Files tab stands in. */ +export interface FolderStep { + id: string; + name: string; +} + +export interface RecentFile { + id: string; + name: string; + timestamp: number; +} + +export const RECENT_FILES_MAX = 20; +/** How many recent files the root of the Files tab lists (webmail RECENT_LIMIT). */ +export const RECENT_FILES_SHOWN = 8; + +/** `/A/B` for the stack A > B; `/` for the root. */ +export function folderPathOf(stack: FolderStep[]): string { + return `/${stack.map((s) => s.name).join('/')}`; +} + +/** Whether `path` is `base` or lies below it. */ +export function isUnderPath(path: string, base: string): boolean { + if (base === '/') return true; + return path === base || path.startsWith(`${base}/`); +} + +/** + * The folder stack a path names, walking the folders the Files tab lists + * from the root down; at each level a folder of the user's own drive wins + * over a shared one of the same name. Null when a part of it is missing. + */ +export function resolveFolderPath(nodes: FileNode[], path: string): FolderStep[] | null { + const names = path.split('/').filter(Boolean); + const stack: FolderStep[] = []; + let parentId: string | null = null; + for (const name of names) { + const matches: FileNode[] = listFolder(nodes, parentId).filter((n) => isFolder(n) && n.name === name); + const next: FileNode | undefined = matches.find((n) => !n.isShared) ?? matches[0]; + if (!next) return null; + stack.push({ id: next.id, name: next.name }); + parentId = next.id; + } + return stack; +} + +/** + * Where a node is listed: the folder stack down to the folder that holds it + * (empty at the root), the way the root listing reaches it. A node of a + * shared drive is reached through the shared root above it. Null when the + * node is gone or no listing reaches it. + */ +export function nodePlace(nodes: FileNode[], id: string): { stack: FolderStep[]; node: FileNode } | null { + const node = nodes.find((n) => n.id === id); + if (!node || node.discoverableOnly) return null; + const rootIds = new Set(listFolder(nodes, null).map((n) => n.id)); + if (rootIds.has(node.id)) return { stack: [], node }; + const byId = new Map(nodes.map((n) => [n.id, n])); + const stack: FolderStep[] = []; + let parentId = node.parentId ?? null; + for (let guard = 0; parentId !== null && guard < 1000; guard++) { + const parent = byId.get(parentId); + if (!parent || parent.discoverableOnly || !isFolder(parent)) return null; + stack.unshift({ id: parent.id, name: parent.name }); + if (rootIds.has(parent.id)) return { stack, node }; + parentId = parent.parentId ?? null; + } + return null; +} + +export function toggleFavorite(favorites: string[], path: string): string[] { + return favorites.includes(path) ? favorites.filter((f) => f !== path) : [...favorites, path]; +} + +/** Stars follow a folder that was renamed or moved, and those below it. */ +export function moveFavorites(favorites: string[], oldPath: string, newPath: string): string[] { + if (oldPath === '/' || oldPath === newPath) return favorites; + const next = favorites.map((f) => (isUnderPath(f, oldPath) ? newPath + f.slice(oldPath.length) : f)); + return next.some((f, i) => f !== favorites[i]) ? next : favorites; +} + +/** Stars of a deleted folder, and of those below it, go. */ +export function dropFavoritesUnder(favorites: string[], path: string): string[] { + if (path === '/') return favorites; + const next = favorites.filter((f) => !isUnderPath(f, path)); + return next.length === favorites.length ? favorites : next; +} + +/** The file goes to the top of the list, once, which keeps the newest 20. */ +export function addRecentFile(recent: RecentFile[], name: string, id: string, now = Date.now()): RecentFile[] { + return [{ id, name, timestamp: now }, ...recent.filter((r) => r.id !== id)].slice(0, RECENT_FILES_MAX); +} + +export function removeRecentFiles(recent: RecentFile[], ids: Iterable): RecentFile[] { + const gone = new Set(ids); + const next = recent.filter((r) => !gone.has(r.id)); + return next.length === recent.length ? recent : next; +} + +/** Recent files whose node is gone or in the trash drop off after a listing. */ +export function pruneRecentFiles(recent: RecentFile[], existingIds: Set): RecentFile[] { + const next = recent.filter((r) => existingIds.has(r.id)); + return next.length === recent.length ? recent : next; +} diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index a8d806ba..c17caa77 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -21,7 +21,7 @@ import { ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo, FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon, MoreVertical, Pencil, Share2, Trash2, Upload, Users, X, Download, Search, - ArrowUpDown, ArrowUp, ArrowDown, FolderInput, Copy, Check, Eye, ExternalLink, FileSymlink, RotateCcw, + ArrowUpDown, ArrowUp, ArrowDown, FolderInput, Copy, Check, Eye, ExternalLink, FileSymlink, RotateCcw, Star, Clock, } from '../components/icons'; import { useFocusEffect } from '@react-navigation/native'; import { File, Paths } from 'expo-file-system'; @@ -85,6 +85,10 @@ import Dialog from '../components/Dialog'; import ShareSheet from '../components/files/ShareSheet'; import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal'; import { previewNeighbours } from '../lib/files-preview-neighbours'; +import { + RECENT_FILES_SHOWN, folderPathOf, nodePlace, resolveFolderPath, type FolderStep, +} from '../lib/files-places'; +import { NO_FILES_PLACES, useFilesPlacesStore } from '../stores/files-places-store'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; // A file row carries the display name alongside the rest of the node. @@ -266,6 +270,9 @@ export default function FilesScreen() { const setSetting = useSettingsStore((s) => s.updateSetting); const activeAccountId = useAuthStore((s) => s.activeAccountId); const session = useAuthStore((s) => s.session); + // Starred folders and recent files of the signed-in account (webmail + // file-store favorites / recentFiles). + const places = useFilesPlacesStore((s) => (activeAccountId ? s.places[activeAccountId] : undefined)) ?? NO_FILES_PLACES; const [viewOverride, setViewOverride] = useState(null); const viewMode: FilesViewMode = viewOverride ?? defaultViewMode; @@ -299,6 +306,7 @@ export default function FilesScreen() { const loadFiles = useCallback( async (mode: 'initial' | 'refresh' | 'quiet' = 'initial') => { const seq = ++loadSeqRef.current; + const account = useAuthStore.getState().activeAccountId; if (mode === 'refresh') setRefreshing(true); else if (mode === 'initial') setLoading(true); if (mode !== 'quiet') setError(null); @@ -306,7 +314,13 @@ export default function FilesScreen() { // One fetch across all accessible accounts: own files plus nodes other // principals shared with us (tagged isShared, ids namespaced). const nodes = await getAllFileNodesAcrossAccounts(); - if (seq === loadSeqRef.current) setAllNodes(nodes); + if (seq === loadSeqRef.current) { + setAllNodes(nodes); + // Recent files that are gone or in the trash drop off. + if (account && account === useAuthStore.getState().activeAccountId) { + useFilesPlacesStore.getState().pruneRecentFiles(account, new Set(withoutTrash(nodes).map((n) => n.id))); + } + } } catch (e) { // A quiet read keeps what is on screen. if (mode !== 'quiet' && seq === loadSeqRef.current) { @@ -451,10 +465,33 @@ export default function FilesScreen() { }); }, []); + // Opened and downloaded files go to the top of Recent (webmail addRecentFile). + const recordRecent = useCallback((row: FileRow) => { + const account = useAuthStore.getState().activeAccountId; + if (!account || row.trash) return; + useFilesPlacesStore.getState().addRecentFile(account, row.displayName, row.id); + }, []); + + // `/A/B` for a folder of the drive, `/` for the root; null when no listing + // reaches it. Stars are kept by path, as in the webmail. + const folderPathFor = useCallback((id: string | null): string | null => { + if (id === null) return '/'; + const place = nodePlace(driveNodes, id); + return place ? folderPathOf([...place.stack, { id: place.node.id, name: place.node.name }]) : null; + }, [driveNodes]); + + const toggleStar = useCallback((row: FileRow) => { + const account = useAuthStore.getState().activeAccountId; + const folderPath = folderPathFor(row.id); + if (!account || !folderPath || folderPath === '/') return; + useFilesPlacesStore.getState().toggleFavorite(account, folderPath); + }, [folderPathFor]); + // Hand the file to another app: the viewer for its type on Android, the // share sheet otherwise. const openFileWith = useCallback(async (row: FileRow) => { if (!row.blobId) return; + recordRecent(row); setBusyId(row.id); try { await shareAttachment(row.blobId, row.displayName, row.type, undefined, row.accountId); @@ -463,12 +500,13 @@ export default function FilesScreen() { } finally { setBusyId(null); } - }, [t]); + }, [recordRecent, t]); // Every way into the preview: a tap, the row's actions, previous / next. const openPreview = useCallback((row: FileRow) => { setPreviewTarget(row); - }, []); + recordRecent(row); + }, [recordRecent]); // Tap on a file: the in-app preview for the types it shows (lib/file-preview), // the other app for the rest. @@ -482,12 +520,13 @@ export default function FilesScreen() { setBusyId(row.id); try { await downloadAttachment(row.blobId, row.displayName, row.type, undefined, row.accountId); + recordRecent(row); } catch (e) { Alert.alert(t('files.download_error', 'Failed to download'), e instanceof Error ? e.message : String(e)); } finally { setBusyId(null); } - }, [t]); + }, [recordRecent, t]); // Batch download: one selected file goes straight to the share sheet; more // than one (or any folder) is bundled into a zip that mirrors the folder @@ -658,13 +697,19 @@ export default function FilesScreen() { const target = renameTarget; setRenameTarget(null); setRenameValue(''); + const oldPath = isFolder(target) ? folderPathFor(target.id) : null; try { await renameFileNode(target.id, name); + // Stars of the folder and below it follow the new name. + const account = useAuthStore.getState().activeAccountId; + if (account && oldPath && oldPath !== '/') { + useFilesPlacesStore.getState().moveFavorites(account, oldPath, oldPath.slice(0, oldPath.lastIndexOf('/') + 1) + name); + } await loadFiles('refresh'); } catch (e) { Alert.alert(t('files.rename_error', 'Failed to rename'), fileErrorMessage(e, t)); } - }, [renameTarget, renameValue, loadFiles, checkName, t]); + }, [renameTarget, renameValue, loadFiles, checkName, folderPathFor, t]); const duplicateFile = useCallback(async (row: FileRow) => { // A folder is copied with everything in it (webmail copyFileNode). @@ -691,15 +736,31 @@ export default function FilesScreen() { return; } setBusyId(row.id); + const oldPath = isFolder(row) ? folderPathFor(row.id) : null; + const destinationPath = folderPathFor(destinationId); try { await moveFileNode(row.id, destinationId); + // A moved folder keeps its stars, at its new path. + const account = useAuthStore.getState().activeAccountId; + if (account && oldPath && destinationPath) { + useFilesPlacesStore.getState().moveFavorites(account, oldPath, `${destinationPath === '/' ? '' : destinationPath}/${row.name}`); + } await loadFiles('refresh'); } catch (e) { Alert.alert(t('files.move_error', 'Failed to move'), fileErrorMessage(e, t)); } finally { setBusyId(null); } - }, [loadFiles, t]); + }, [loadFiles, folderPathFor, t]); + + // Stars of deleted folders, and of those below them, go (webmail + // deleteNode / trashResources); recent files drop off with the next + // listing. + const dropStarsOf = useCallback((paths: Array) => { + const account = useAuthStore.getState().activeAccountId; + if (!account) return; + for (const p of paths) if (p && p !== '/') useFilesPlacesStore.getState().dropFavoritesUnder(account, p); + }, []); const cancelUpload = useCallback(() => { uploadAbortRef.current?.abort(); @@ -799,9 +860,11 @@ export default function FilesScreen() { const trashRows = useCallback(async (rows: FileRow[]) => { clearSelection(); const listing = allNodes; + const folderPaths = new Map(rows.filter((r) => isFolder(r)).map((r) => [r.id, folderPathFor(r.id)])); try { const moved = await moveToTrash(listing, rows); const { done, refused } = moved; + dropStarsOf(done.map((item) => folderPaths.get(item.node.id) ?? null)); await loadFiles('refresh'); if (refused.length > 0) { Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(refused[0], t)); @@ -817,7 +880,7 @@ export default function FilesScreen() { else Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t)); await loadFiles('refresh'); } - }, [allNodes, clearSelection, loadFiles, undoTrash, t]); + }, [allNodes, clearSelection, loadFiles, undoTrash, folderPathFor, dropStarsOf, t]); const requestDelete = useCallback((rows: FileRow[]) => { if (rows.length === 0) return; @@ -836,7 +899,11 @@ export default function FilesScreen() { // the trash only what is still there goes (see purgeFromTrash). let count = targets.length; if (fromTrash.length > 0) count = await purgeFromTrash(fromTrash); - else await deleteFileNodes(targets.map((r) => r.id)); + else { + const folderPaths = targets.filter((r) => isFolder(r)).map((r) => folderPathFor(r.id)); + await deleteFileNodes(targets.map((r) => r.id)); + dropStarsOf(folderPaths); + } if (count > 0) { toast.success(t('files.deleted_forever', '{count, plural, one {Deleted permanently} other {# items deleted permanently}}', { count, @@ -846,7 +913,7 @@ export default function FilesScreen() { Alert.alert(t('files.delete_error', 'Failed to delete'), fileErrorMessage(e, t)); } await loadFiles('refresh'); - }, [confirmDelete, clearSelection, loadFiles, t]); + }, [confirmDelete, clearSelection, loadFiles, folderPathFor, dropStarsOf, t]); const restoreRows = useCallback(async (rows: FileRow[]) => { const items = rows.flatMap((r) => (r.trash ? [r.trash] : [])); @@ -1227,6 +1294,121 @@ export default function FilesScreen() { ); }; + // Starred folders and recent files, listed above the root's files (the + // webmail's sidebar sections). A star whose folder is gone is not shown. + const showPlaces = path.length === 0 && !inTrash && !searchQuery.trim(); + const starredFolders = useMemo( + () => places.favorites.flatMap((p) => { + const stack = resolveFolderPath(driveNodes, p); + return stack && stack.length > 0 ? [{ path: p, stack }] : []; + }), + [places.favorites, driveNodes], + ); + const recentFiles = places.recent.slice(0, RECENT_FILES_SHOWN); + const openFolderStack = (stack: FolderStep[]) => { + setInTrash(false); + setPath(stack); + setSearchQuery(''); + clearSelection(); + }; + // A recent file can live in any folder: open that folder, then the file. + const openRecent = (id: string, reveal = false) => { + const place = nodePlace(driveNodes, id); + if (!place || isFolder(place.node)) { + toast.error(t('deep_link.file_not_found', 'This file is no longer available.')); + return; + } + openFolderStack(place.stack); + if (!reveal && !isSymlink(place.node)) openFile({ ...place.node, displayName: place.node.name }); + }; + const recentActions = (id: string, name: string) => { + Alert.alert(name, undefined, [ + { text: t('files.open', 'Open'), onPress: () => openRecent(id) }, + { text: t('files.show_in_folder', 'Show in folder'), onPress: () => openRecent(id, true) }, + { + text: t('files.remove_from_recent', 'Remove from recent'), + onPress: () => { + if (activeAccountId) useFilesPlacesStore.getState().removeRecentFiles(activeAccountId, [id]); + }, + }, + { text: t('files.cancel', 'Cancel'), style: 'cancel' }, + ]); + }; + const starredActions = (folderPath: string, name: string) => { + Alert.alert(name, undefined, [ + { + text: t('files.unstar', 'Remove star'), + onPress: () => { + if (activeAccountId) useFilesPlacesStore.getState().toggleFavorite(activeAccountId, folderPath); + }, + }, + { text: t('files.cancel', 'Cancel'), style: 'cancel' }, + ]); + }; + const placesHeader = showPlaces && (starredFolders.length > 0 || recentFiles.length > 0) ? ( + + {starredFolders.length > 0 ? ( + {t('files.favorites', 'Starred')} + ) : null} + {starredFolders.map(({ path: folderPath, stack }) => { + const name = stack[stack.length - 1].name; + return ( + openFolderStack(stack)} + onLongPress={() => starredActions(folderPath, name)} + delayLongPress={300} + style={({ pressed }) => [styles.fileRow, pressed && { backgroundColor: c.surfaceHover }]} + accessibilityRole="button" + > + + {name} + starredActions(folderPath, name)} + hitSlop={8} + style={styles.rowMore} + accessibilityRole="button" + accessibilityLabel={t('files.context_menu', 'Actions')} + > + + + + ); + })} + {recentFiles.length > 0 ? ( + {t('files.recent', 'Recent')} + ) : null} + {recentFiles.map((entry) => { + const Icon = pickFileIcon(entry.name); + return ( + openRecent(entry.id)} + onLongPress={() => recentActions(entry.id, entry.name)} + delayLongPress={300} + style={({ pressed }) => [styles.fileRow, pressed && { backgroundColor: c.surfaceHover }]} + accessibilityRole="button" + > + {showIcons ? ( + + ) : } + {entry.name} + recentActions(entry.id, entry.name)} + hitSlop={8} + style={styles.rowMore} + accessibilityRole="button" + accessibilityLabel={t('files.context_menu', 'Actions')} + > + + + + ); + })} + {rootLabel} + + ) : null; + let body: React.ReactNode; if (loading && !refreshing) { body = ( @@ -1281,6 +1463,7 @@ export default function FilesScreen() { contentContainerStyle={styles.gridContent} onLayout={(e) => setGridWidth(Math.round(e.nativeEvent.layout.width))} renderItem={({ item }) => {renderGridCell(item)}} + ListHeaderComponent={placesHeader} keyboardShouldPersistTaps="handled" refreshControl={ @@ -1294,6 +1477,7 @@ export default function FilesScreen() { key="list" keyExtractor={(item) => item.id} renderItem={({ item }) => renderRow(item)} + ListHeaderComponent={placesHeader} keyboardShouldPersistTaps="handled" refreshControl={ @@ -1405,6 +1589,13 @@ export default function FilesScreen() { setActionsTarget(null); void restoreRows([r]); }} + starred={actionsTarget && isFolder(actionsTarget) && !actionsTarget.trash + ? places.favorites.includes(folderPathFor(actionsTarget.id) ?? '') + : null} + onToggleStar={(r) => { + setActionsTarget(null); + toggleStar(r); + }} /> void; /** For a row of the trash: put it back where it came from. */ onRestore: (r: FileRow) => void; + /** For a folder: whether it is starred; null hides Star. */ + starred: boolean | null; + onToggleStar: (r: FileRow) => void; } function ActionsSheet({ target, access, sharingEnabled, t, onClose, onPreview, onOpenWith, onDownload, onShare, onMove, onDuplicate, onRename, onDelete, - onRestore, + onRestore, starred, onToggleStar, }: ActionsSheetProps) { // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); @@ -1659,6 +1853,12 @@ function ActionsSheet({ {t('files.download', 'Download')} ) : null} + {starred !== null ? ( + onToggleStar(target)}> + + {starred ? t('files.unstar', 'Remove star') : t('files.star', 'Star')} + + ) : null} {canShare ? ( onShare(target)}> @@ -1997,6 +2197,20 @@ function makeStyles(c: ThemePalette) { fileInfo: { flex: 1, }, + places: { + paddingTop: spacing.xs, + }, + placesTitle: { + fontSize: typography.caption.fontSize, + fontWeight: '600' as const, + color: c.textMuted, + paddingHorizontal: spacing.md, + paddingTop: spacing.md, + paddingBottom: spacing.xs, + }, + placeName: { + flex: 1, + }, fileNameRow: { flexDirection: 'row', alignItems: 'center', diff --git a/src/stores/files-places-store.ts b/src/stores/files-places-store.ts new file mode 100644 index 00000000..7efc4966 --- /dev/null +++ b/src/stores/files-places-store.ts @@ -0,0 +1,68 @@ +// Starred folders and recent files of the Files tab, kept on the device per +// signed-in account: the webmail keeps them in localStorage, and ids and +// paths of one account mean nothing in another. +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { createPersistStorage } from './persist-storage'; +import { + addRecentFile, + dropFavoritesUnder, + moveFavorites, + pruneRecentFiles, + removeRecentFiles, + toggleFavorite, + type RecentFile, +} from '../lib/files-places'; + +export interface FilesPlaces { + favorites: string[]; + recent: RecentFile[]; +} + +/** What an account without starred or recent files has; one shared object. */ +export const NO_FILES_PLACES: FilesPlaces = Object.freeze({ favorites: [], recent: [] }) as FilesPlaces; + +interface FilesPlacesState { + /** By signed-in account id. */ + places: Record; + toggleFavorite: (account: string, path: string) => void; + moveFavorites: (account: string, oldPath: string, newPath: string) => void; + dropFavoritesUnder: (account: string, path: string) => void; + addRecentFile: (account: string, name: string, id: string) => void; + removeRecentFiles: (account: string, ids: Iterable) => void; + pruneRecentFiles: (account: string, existingIds: Set) => void; +} + +export const useFilesPlacesStore = create()( + persist( + (set, get) => { + const update = (account: string, change: (p: FilesPlaces) => FilesPlaces) => { + if (!account) return; + const current = get().places[account] ?? NO_FILES_PLACES; + const next = change(current); + if (next.favorites === current.favorites && next.recent === current.recent) return; + set({ places: { ...get().places, [account]: next } }); + }; + return { + places: {}, + toggleFavorite: (account, path) => + update(account, (p) => ({ ...p, favorites: toggleFavorite(p.favorites, path) })), + moveFavorites: (account, oldPath, newPath) => + update(account, (p) => ({ ...p, favorites: moveFavorites(p.favorites, oldPath, newPath) })), + dropFavoritesUnder: (account, path) => + update(account, (p) => ({ ...p, favorites: dropFavoritesUnder(p.favorites, path) })), + addRecentFile: (account, name, id) => + update(account, (p) => ({ ...p, recent: addRecentFile(p.recent, name, id) })), + removeRecentFiles: (account, ids) => + update(account, (p) => ({ ...p, recent: removeRecentFiles(p.recent, ids) })), + pruneRecentFiles: (account, existingIds) => + update(account, (p) => ({ ...p, recent: pruneRecentFiles(p.recent, existingIds) })), + }; + }, + { + name: 'files-places', + storage: createPersistStorage(), + partialize: (state) => ({ places: state.places }), + }, + ), +);