feat: list starred folders and recent files at the top of files

This commit is contained in:
Linus Rath
2026-10-31 05:28:20 +01:00
parent e3fc67e771
commit cad9296a84
4 changed files with 521 additions and 11 deletions
+117
View File
@@ -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<FileNode> = {}) =>
({ id, name, parentId, blobId: null, ...extra }) as FileNode;
const file = (id: string, name: string, parentId: string | null = null, extra: Partial<FileNode> = {}) =>
({ 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'\)/);
});
});
+111
View File
@@ -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<string>): 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<string>): RecentFile[] {
const next = recent.filter((r) => existingIds.has(r.id));
return next.length === recent.length ? recent : next;
}
+225 -11
View File
@@ -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<FilesViewMode | null>(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<string | null>) => {
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) ? (
<View style={styles.places}>
{starredFolders.length > 0 ? (
<Text style={styles.placesTitle} accessibilityRole="header">{t('files.favorites', 'Starred')}</Text>
) : null}
{starredFolders.map(({ path: folderPath, stack }) => {
const name = stack[stack.length - 1].name;
return (
<Pressable
key={folderPath}
onPress={() => openFolderStack(stack)}
onLongPress={() => starredActions(folderPath, name)}
delayLongPress={300}
style={({ pressed }) => [styles.fileRow, pressed && { backgroundColor: c.surfaceHover }]}
accessibilityRole="button"
>
<Star size={20} color={c.starred} fill={c.starred} />
<Text style={[styles.fileName, styles.placeName]} numberOfLines={1}>{name}</Text>
<Pressable
onPress={() => starredActions(folderPath, name)}
hitSlop={8}
style={styles.rowMore}
accessibilityRole="button"
accessibilityLabel={t('files.context_menu', 'Actions')}
>
<MoreVertical size={18} color={c.textMuted} />
</Pressable>
</Pressable>
);
})}
{recentFiles.length > 0 ? (
<Text style={styles.placesTitle} accessibilityRole="header">{t('files.recent', 'Recent')}</Text>
) : null}
{recentFiles.map((entry) => {
const Icon = pickFileIcon(entry.name);
return (
<Pressable
key={entry.id}
onPress={() => openRecent(entry.id)}
onLongPress={() => recentActions(entry.id, entry.name)}
delayLongPress={300}
style={({ pressed }) => [styles.fileRow, pressed && { backgroundColor: c.surfaceHover }]}
accessibilityRole="button"
>
{showIcons ? (
<Icon size={20} color={coloredIcons ? fileIconColor(entry.name, c) : c.textMuted} />
) : <Clock size={20} color={c.textMuted} />}
<Text style={[styles.fileName, styles.placeName]} numberOfLines={1}>{entry.name}</Text>
<Pressable
onPress={() => recentActions(entry.id, entry.name)}
hitSlop={8}
style={styles.rowMore}
accessibilityRole="button"
accessibilityLabel={t('files.context_menu', 'Actions')}
>
<MoreVertical size={18} color={c.textMuted} />
</Pressable>
</Pressable>
);
})}
<Text style={styles.placesTitle} accessibilityRole="header">{rootLabel}</Text>
</View>
) : 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 }) => <View style={{ width: grid.cellWidth }}>{renderGridCell(item)}</View>}
ListHeaderComponent={placesHeader}
keyboardShouldPersistTaps="handled"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
@@ -1294,6 +1477,7 @@ export default function FilesScreen() {
key="list"
keyExtractor={(item) => item.id}
renderItem={({ item }) => renderRow(item)}
ListHeaderComponent={placesHeader}
keyboardShouldPersistTaps="handled"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
@@ -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);
}}
/>
<FolderPickerSheet
@@ -1556,11 +1747,14 @@ interface ActionsSheetProps {
onDelete: (r: FileRow) => 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({
<Text style={styles.actionLabel}>{t('files.download', 'Download')}</Text>
</Pressable>
) : null}
{starred !== null ? (
<Pressable style={styles.action} onPress={() => onToggleStar(target)}>
<Star size={18} color={starred ? c.starred : c.text} fill={starred ? c.starred : 'transparent'} />
<Text style={styles.actionLabel}>{starred ? t('files.unstar', 'Remove star') : t('files.star', 'Star')}</Text>
</Pressable>
) : null}
{canShare ? (
<Pressable style={styles.action} onPress={() => onShare(target)}>
<Users size={18} color={c.text} />
@@ -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',
+68
View File
@@ -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<string, FilesPlaces>;
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<string>) => void;
pruneRecentFiles: (account: string, existingIds: Set<string>) => void;
}
export const useFilesPlacesStore = create<FilesPlacesState>()(
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 }),
},
),
);