mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: list starred folders and recent files at the top of files
This commit is contained in:
@@ -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'\)/);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user