Files
native/src/screens/FilesScreen.tsx
T

1777 lines
63 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Image,
Modal,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
TouchableWithoutFeedback,
View,
useWindowDimensions,
} from 'react-native';
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,
} from '../components/icons';
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import JSZip from 'jszip';
// expo-document-picker is loaded lazily on first upload. Its native module
// is registered at app launch via Expo autolinking; on builds that predate
// the dep being added, requiring it at import time crashes the whole bundle
// with "Cannot find native module 'ExpoDocumentPicker'". Deferring the
// require keeps every other Files tab feature working until the user
// rebuilds the native shell.
type DocumentPickerModule = typeof import('expo-document-picker');
let documentPickerModule: DocumentPickerModule | null = null;
function loadDocumentPicker(): DocumentPickerModule | null {
if (documentPickerModule) return documentPickerModule;
try {
documentPickerModule = require('expo-document-picker') as DocumentPickerModule;
return documentPickerModule;
} catch {
return null;
}
}
import {
copyFileNode, createFolder, deleteFileNodes, getAllFileNodesAcrossAccounts,
getFileNodeDownloadUrl, getMaxSizeUpload, isCrossAccountId, isFolder, moveFileNode,
renameFileNode, supportsSharing, uploadFileNode,
} from '../api/files';
import { jmapClient } from '../api/jmap-client';
import { downloadAttachment, shareAttachment } from '../lib/email-export';
import { secureFetch } from '../lib/client-cert';
import { getUniqueName } from '../lib/filenode-name';
import { filesGridLayout } from '../lib/files-grid';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { formatListDate } from '../lib/date-format';
import { useLayout, useSheetInsets } from '../lib/use-layout';
import { useBackWhileFocused } from '../lib/use-back-while-focused';
import { filesBackStep } from '../lib/files-back';
import type { FileNode } from '../api/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import {
useSettingsStore, type FilesViewMode, type FilesSortKey, type FilesSortDir,
} from '../stores/settings-store';
import { useAuthStore } from '../stores/auth-store';
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
import Dialog from '../components/Dialog';
import ShareSheet from '../components/files/ShareSheet';
import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
// A file row carries the display name alongside the rest of the node.
interface FileRow extends FileNode {
displayName: string;
}
type Translate = TranslateFn;
const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'];
function fileExt(name: string): string {
return name.split('.').pop()?.toLowerCase() ?? '';
}
function isImageName(name: string): boolean {
return IMAGE_EXTS.includes(fileExt(name));
}
function pickFileIcon(name: string) {
const ext = fileExt(name);
if ([...IMAGE_EXTS, 'svg'].includes(ext)) return FileImage;
if (['mp4', 'mov', 'webm', 'avi', 'mkv'].includes(ext)) return FileVideo;
if (['mp3', 'wav', 'flac', 'm4a', 'ogg', 'opus'].includes(ext)) return FileAudio;
if (['zip', 'rar', 'tar', 'gz', '7z', 'bz2'].includes(ext)) return FileArchive;
if (['xlsx', 'xls', 'csv', 'numbers'].includes(ext)) return FileSpreadsheet;
if (['ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'json', 'xml', 'yaml', 'yml', 'sh'].includes(ext)) return FileCode2;
return FileText;
}
function fileIconColor(name: string, c: ThemePalette): string {
const ext = fileExt(name);
if ([...IMAGE_EXTS, 'svg'].includes(ext)) return c.calendar.green;
if (['mp4', 'mov', 'webm', 'avi', 'mkv'].includes(ext)) return c.calendar.purple;
if (['mp3', 'wav', 'flac', 'm4a', 'ogg', 'opus'].includes(ext)) return c.calendar.pink;
if (['zip', 'rar', 'tar', 'gz', '7z', 'bz2'].includes(ext)) return c.calendar.orange;
if (['xlsx', 'xls', 'csv', 'numbers'].includes(ext)) return c.calendar.teal;
if (['pdf'].includes(ext)) return c.error;
if (['ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'json', 'xml', 'yaml', 'yml', 'sh'].includes(ext)) return c.calendar.indigo;
return c.textMuted;
}
export function formatFileSize(bytes?: number): string {
if (bytes == null) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
/**
* Formats a file's modified time like the mail list does: in the app's
* language and with the 12/24 h and date settings, instead of the device
* defaults ("10:47 PM" next to 24 h times everywhere else).
*/
function useFormatModified(): (iso?: string) => string {
const dateFormat = useSettingsStore((s) => s.dateFormat);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const locale = useLocaleStore((s) => s.locale);
const t = useLocaleStore((s) => s.t);
return React.useCallback(
(iso?: string) => (iso ? formatListDate(iso, { dateFormat, timeFormat, locale, t }) : ''),
[dateFormat, timeFormat, locale, t],
);
}
// Last-modified with the creation time as fallback (webmail file-store
// `lastModified: node.modified || node.created`).
function modifiedOf(node: FileNode): string | undefined {
return node.modified || node.created;
}
// Authenticated thumbnail URL/headers for image nodes. The download endpoint
// needs the bearer/basic header; RN's Image passes `headers` through.
function thumbnailSource(node: FileNode): { uri: string; headers: Record<string, string> } | null {
if (!node.blobId || !isImageName(node.name)) return null;
try {
return {
uri: getFileNodeDownloadUrl(node),
headers: { Authorization: jmapClient.authHeader },
};
} catch {
return null;
}
}
interface UploadProgress {
current: number;
total: number;
name: string;
percent: number; // 0..1
}
export default function FilesScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const formatModified = useFormatModified();
// Clear of the status bar (the header used a fixed 60 px top padding); the
// whole screen keeps clear of the side insets in landscape.
const insets = useSafeAreaInsets();
const headerInsets = { paddingTop: insets.top + spacing.sm, paddingHorizontal: spacing.md };
// Folder navigation stack. Hierarchy is real parentId nesting (#379) — each
// entry is the folder's node id (namespaced "accountId:nodeId" inside a
// shared-with-me subtree) plus its name for the breadcrumb.
const [path, setPath] = useState<{ id: string; name: string }[]>([]);
const [allNodes, setAllNodes] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selection, setSelection] = useState<Set<string>>(new Set());
const [busyId, setBusyId] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState<UploadProgress | null>(null);
const uploadAbortRef = useRef<AbortController | null>(null);
const [batchBusy, setBatchBusy] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[] } | null>(null);
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
const [renameValue, setRenameValue] = useState('');
const [actionsTarget, setActionsTarget] = useState<FileRow | null>(null);
const [shareTarget, setShareTarget] = useState<FileRow | null>(null);
const [moveTarget, setMoveTarget] = useState<FileRow | null>(null);
const [previewTarget, setPreviewTarget] = useState<FileRow | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [sortSheetOpen, setSortSheetOpen] = useState(false);
const showIcons = useSettingsStore((s) => s.filesShowIcons);
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
const showThumbnails = useSettingsStore((s) => s.filesShowThumbnails);
const showHiddenFiles = useSettingsStore((s) => s.filesShowHiddenFiles);
const sortKey = useSettingsStore((s) => s.filesDefaultSortKey);
const sortDir = useSettingsStore((s) => s.filesDefaultSortDir);
const defaultViewMode = useSettingsStore((s) => s.filesDefaultViewMode);
const setSetting = useSettingsStore((s) => s.updateSetting);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const [viewOverride, setViewOverride] = useState<FilesViewMode | null>(null);
const viewMode: FilesViewMode = viewOverride ?? defaultViewMode;
const { isSplit } = useLayout();
// The grid's columns follow its measured width (rotation, tablet panes).
const { width: windowWidth } = useWindowDimensions();
const [gridWidth, setGridWidth] = React.useState(0);
const grid = filesGridLayout(gridWidth || windowWidth);
const uploading = uploadProgress != null;
const selectionMode = selection.size > 0;
const currentParentId = path.length === 0 ? null : path[path.length - 1].id;
// True while browsing inside a folder another principal shared with us.
// Creates/uploads would have to route to the owner's account, which this
// screen doesn't do (matches the webmail's Files app); writes are hidden.
const inSharedSubtree = isCrossAccountId(currentParentId);
const rootLabel = t('files.title', 'Files');
const headerName = path.length === 0 ? rootLabel : path[path.length - 1].name;
const sharingEnabled = supportsSharing();
const loadFiles = useCallback(
async (mode: 'initial' | 'refresh' = 'initial') => {
if (mode === 'refresh') setRefreshing(true);
else setLoading(true);
setError(null);
try {
// One fetch across all accessible accounts: own files plus nodes other
// principals shared with us (tagged isShared, ids namespaced).
const nodes = await getAllFileNodesAcrossAccounts();
setAllNodes(nodes);
} catch (e) {
setError(e instanceof Error ? e.message : t('files.download_error', 'Failed to load files'));
} finally {
setLoading(false);
setRefreshing(false);
}
},
[t],
);
// Load on mount and reset the whole drive whenever the active account
// changes: the node cache, folder stack and selection all belong to the
// previous account (webmail 1.9.0 "reset the account-scoped Files drive").
const loadedForAccountRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
if (loadedForAccountRef.current !== undefined && loadedForAccountRef.current !== activeAccountId) {
uploadAbortRef.current?.abort();
setAllNodes([]);
setPath([]);
setSelection(new Set());
setActionsTarget(null);
setShareTarget(null);
setMoveTarget(null);
setPreviewTarget(null);
setSearchQuery('');
}
loadedForAccountRef.current = activeAccountId;
void loadFiles();
}, [activeAccountId, loadFiles]);
// If we navigated into a folder that no longer exists (deleted, or its
// share was revoked), cut the stack back to the deepest ancestor that does.
useEffect(() => {
if (allNodes.length === 0) return;
setPath((prev) => {
const byId = new Map(allNodes.map((n) => [n.id, n]));
let cut = prev.length;
for (let i = 0; i < prev.length; i++) {
const node = byId.get(prev[i].id);
if (!node || !isFolder(node)) {
cut = i;
break;
}
}
return cut === prev.length ? prev : prev.slice(0, cut);
});
}, [allNodes]);
const folderNodes = useMemo(() => {
if (currentParentId === null) {
// Root: our own top-level nodes, plus the roots of every shared-with-me
// subtree (a shared node whose parent isn't visible to us).
const idSet = new Set(allNodes.map((n) => n.id));
return allNodes.filter((n) =>
n.isShared
? n.parentId == null || !idSet.has(n.parentId)
: (n.parentId ?? null) === null,
);
}
return allNodes.filter((n) => (n.parentId ?? null) === currentParentId);
}, [allNodes, currentParentId]);
const visibleFiles = useMemo<FileRow[]>(() => {
const q = searchQuery.trim().toLowerCase();
const rows = folderNodes
.map<FileRow>((n) => ({ ...n, displayName: n.name }))
.filter((r) => showHiddenFiles || !r.displayName.startsWith('.'))
.filter((r) => !q || r.displayName.toLowerCase().includes(q));
const dir = sortDir === 'desc' ? -1 : 1;
return rows.sort((a, b) => {
// Own content first, shared-with-me entries after (root only).
const aShared = a.isShared ? 1 : 0;
const bShared = b.isShared ? 1 : 0;
if (aShared !== bShared) return aShared - bShared;
const aDir = isFolder(a) ? 0 : 1;
const bDir = isFolder(b) ? 0 : 1;
if (aDir !== bDir) return aDir - bDir;
switch (sortKey) {
case 'size':
return ((a.size ?? 0) - (b.size ?? 0)) * dir;
case 'modified': {
const am = modifiedOf(a) ? new Date(modifiedOf(a)!).getTime() : 0;
const bm = modifiedOf(b) ? new Date(modifiedOf(b)!).getTime() : 0;
return (am - bm) * dir;
}
case 'name':
default:
return a.displayName.localeCompare(b.displayName) * dir;
}
});
}, [folderNodes, searchQuery, showHiddenFiles, sortKey, sortDir]);
const clearSelection = useCallback(() => setSelection(new Set()), []);
// Shared-with-me rows stay out of multi-select: batch delete routes to our
// own account and would fail (or worse, mismatch) on namespaced ids.
const toggleSelect = useCallback((row: FileRow) => {
if (row.isShared) return;
setSelection((prev) => {
const next = new Set(prev);
if (next.has(row.id)) next.delete(row.id);
else next.add(row.id);
return next;
});
}, []);
// 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;
setBusyId(row.id);
try {
await shareAttachment(row.blobId, row.displayName, row.type, undefined, row.accountId);
} catch (e) {
Alert.alert(t('files.preview_error', 'Failed to load preview'), e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
}, [t]);
// Tap on a file: the in-app preview for the types it shows (lib/file-preview),
// the other app for the rest.
const openFile = useCallback((row: FileRow) => {
if (canPreviewInApp(row)) setPreviewTarget(row);
else void openFileWith(row);
}, [openFileWith]);
const downloadFile = useCallback(async (row: FileRow) => {
if (!row.blobId) return;
setBusyId(row.id);
try {
await downloadAttachment(row.blobId, row.displayName, row.type, undefined, row.accountId);
} catch (e) {
Alert.alert(t('files.download_error', 'Failed to download'), e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
}, [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
// structure, then handed to the share sheet (webmail "Download (N)").
const downloadSelection = useCallback(async (rows: FileRow[]) => {
if (rows.length === 0) return;
if (rows.length === 1 && !isFolder(rows[0])) {
clearSelection();
await downloadFile(rows[0]);
return;
}
// Collect every file below the selection with its relative path.
const children = new Map<string, FileNode[]>();
for (const n of allNodes) {
const key = n.parentId ?? '';
if (!children.has(key)) children.set(key, []);
children.get(key)!.push(n);
}
const entries: { node: FileNode; path: string }[] = [];
const walk = (node: FileNode, prefix: string) => {
if (isFolder(node)) {
for (const child of children.get(node.id) ?? []) walk(child, `${prefix}${node.name}/`);
} else {
entries.push({ node, path: `${prefix}${node.name}` });
}
};
for (const row of rows) walk(row, '');
if (entries.length === 0) {
Alert.alert(t('files.download_error', 'Failed to download'), t('files.empty_state_title', 'No files yet'));
return;
}
clearSelection();
setBatchBusy(`0/${entries.length}`);
try {
const zip = new JSZip();
const header = { Authorization: jmapClient.authHeader };
for (let i = 0; i < entries.length; i++) {
const { node, path: rel } = entries[i];
setBatchBusy(`${i + 1}/${entries.length}`);
const res = await secureFetch(getFileNodeDownloadUrl(node), { headers: header });
if (!res.ok) throw new Error(`${node.name}: ${res.status}`);
zip.file(rel, await res.arrayBuffer());
}
const bytes = await zip.generateAsync({ type: 'uint8array' });
const baseName = rows.length === 1 ? rows[0].name : (path[path.length - 1]?.name ?? rootLabel);
const dest = new File(Paths.cache, `${baseName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
if (dest.exists) dest.delete();
dest.create();
dest.write(bytes);
if (!(await Sharing.isAvailableAsync())) {
throw new Error(t('files.share_unavailable', 'Sharing is not available on this device'));
}
await Sharing.shareAsync(dest.uri, { mimeType: 'application/zip', dialogTitle: dest.name });
} catch (e) {
Alert.alert(t('files.download_error', 'Failed to download'), e instanceof Error ? e.message : String(e));
} finally {
setBatchBusy(null);
}
}, [allNodes, clearSelection, downloadFile, path, rootLabel, t]);
const handleRowPress = useCallback(
(row: FileRow) => {
if (selectionMode) {
toggleSelect(row);
return;
}
if (isFolder(row)) {
setPath((p) => [...p, { id: row.id, name: row.displayName }]);
setSearchQuery('');
return;
}
openFile(row);
},
[selectionMode, toggleSelect, openFile],
);
const handleRowLongPress = useCallback((row: FileRow) => {
toggleSelect(row);
}, [toggleSelect]);
const goBack = useCallback(() => {
setPath((p) => (p.length > 0 ? p.slice(0, -1) : p));
setSearchQuery('');
clearSelection();
}, [clearSelection]);
// Android back closes the preview, then leaves selection mode, then goes
// up one folder; only at the root does it switch to the Mail tab.
const backStep = filesBackStep({
previewOpen: previewTarget != null,
selecting: selectionMode,
folderDepth: path.length,
});
const handleBack = useCallback(() => {
if (backStep === 'close-preview') setPreviewTarget(null);
else if (backStep === 'clear-selection') clearSelection();
else if (backStep === 'folder-up') goBack();
}, [backStep, clearSelection, goBack]);
useBackWhileFocused(backStep !== 'leave', handleBack);
const toggleView = useCallback(() => {
const next: FilesViewMode = viewMode === 'list' ? 'grid' : 'list';
setViewOverride(next);
setSetting('filesDefaultViewMode', next);
}, [viewMode, setSetting]);
const onRefresh = useCallback(() => {
void loadFiles('refresh');
}, [loadFiles]);
const invalidNameAlert = useCallback(() => {
Alert.alert(t('files.invalid_name', 'Invalid name'), t('files.invalid_name_slash', 'Names cannot contain "/".'));
}, [t]);
const submitNewFolder = useCallback(async () => {
const name = newFolderName.trim();
if (!name) return;
if (name.includes('/')) {
invalidNameAlert();
return;
}
setNewFolderOpen(false);
setNewFolderName('');
try {
await createFolder(name, currentParentId);
await loadFiles('refresh');
} catch (e) {
Alert.alert(t('files.create_folder_error', 'Failed to create folder'), e instanceof Error ? e.message : String(e));
}
}, [newFolderName, currentParentId, loadFiles, invalidNameAlert, t]);
const submitRename = useCallback(async () => {
if (!renameTarget) return;
const name = renameValue.trim();
if (!name || name === renameTarget.displayName) {
setRenameTarget(null);
return;
}
if (name.includes('/')) {
invalidNameAlert();
return;
}
const target = renameTarget;
setRenameTarget(null);
setRenameValue('');
try {
await renameFileNode(target.id, name);
await loadFiles('refresh');
} catch (e) {
Alert.alert(t('files.rename_error', 'Failed to rename'), e instanceof Error ? e.message : String(e));
}
}, [renameTarget, renameValue, loadFiles, invalidNameAlert, t]);
const duplicateFile = useCallback(async (row: FileRow) => {
if (isFolder(row) || row.isShared) return;
setBusyId(row.id);
try {
const siblings = new Set(
allNodes.filter((n) => (n.parentId ?? null) === (row.parentId ?? null)).map((n) => n.name),
);
await copyFileNode(row, row.parentId ?? null, getUniqueName(row.name, siblings));
await loadFiles('refresh');
} catch (e) {
Alert.alert(t('files.duplicate_error', 'Failed to duplicate'), e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
}, [allNodes, loadFiles, t]);
const performMove = useCallback(async (row: FileRow, destinationId: string | null) => {
setMoveTarget(null);
if ((row.parentId ?? null) === destinationId) return;
setBusyId(row.id);
try {
await moveFileNode(row.id, destinationId);
await loadFiles('refresh');
} catch (e) {
Alert.alert(t('files.move_error', 'Failed to move'), e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
}, [loadFiles, t]);
const cancelUpload = useCallback(() => {
uploadAbortRef.current?.abort();
}, []);
const startUpload = useCallback(async () => {
if (uploading) return;
const picker = loadDocumentPicker();
if (!picker) {
Alert.alert(
t('files.upload_error', 'Failed to upload file'),
t('files.upload_unavailable', 'The file picker is not installed in this build. Rebuild the app to enable uploads.'),
);
return;
}
let result;
try {
result = await picker.getDocumentAsync({
copyToCacheDirectory: true,
multiple: true,
});
} catch (e) {
Alert.alert(t('files.upload_error', 'Failed to upload file'), e instanceof Error ? e.message : String(e));
return;
}
if (result.canceled || result.assets.length === 0) return;
// Skip anything over the server's advertised ceiling up front instead of
// letting the upload endpoint answer with an opaque 4xx.
const maxSize = getMaxSizeUpload();
const accepted: typeof result.assets = [];
for (const asset of result.assets) {
let size = asset.size;
if (size == null) {
try { size = new File(asset.uri).size ?? undefined; } catch { /* unknown size */ }
}
if (maxSize > 0 && size != null && size > maxSize) {
Alert.alert(
t('files.upload_error', 'Failed to upload file'),
t('files.file_too_large', '"{name}" exceeds the maximum file size ({max})', {
name: asset.name,
max: formatFileSize(maxSize),
}),
);
continue;
}
accepted.push(asset);
}
if (accepted.length === 0) return;
const controller = new AbortController();
uploadAbortRef.current = controller;
const parentId = currentParentId;
// Duplicate names get " (1)", " (2)", ... so two nodes never share a name.
const existing = new Set(
allNodes.filter((n) => (n.parentId ?? null) === parentId).map((n) => n.name),
);
let failed: string | null = null;
try {
for (let i = 0; i < accepted.length; i++) {
if (controller.signal.aborted) break;
const asset = accepted[i];
const name = getUniqueName(asset.name, existing);
existing.add(name);
setUploadProgress({ current: i + 1, total: accepted.length, name, percent: 0 });
try {
await uploadFileNode(
asset.uri,
name,
asset.mimeType || 'application/octet-stream',
parentId,
{
signal: controller.signal,
onProgress: (sent, total) =>
setUploadProgress((p) => (p ? { ...p, percent: total > 0 ? sent / total : 0 } : p)),
},
);
} catch (e) {
if (controller.signal.aborted || (e instanceof Error && e.name === 'AbortError')) break;
failed = `${asset.name}: ${e instanceof Error ? e.message : String(e)}`;
break;
}
}
} finally {
uploadAbortRef.current = null;
setUploadProgress(null);
}
await loadFiles('refresh');
if (failed) Alert.alert(t('files.upload_error', 'Failed to upload file'), failed);
}, [uploading, currentParentId, allNodes, loadFiles, t]);
const requestDelete = useCallback((rows: FileRow[]) => {
if (rows.length === 0) return;
setConfirmDelete({ rows });
}, []);
const performDelete = useCallback(async () => {
if (!confirmDelete) return;
const targets = confirmDelete.rows;
setConfirmDelete(null);
clearSelection();
try {
// The server removes folder descendants (onDestroyRemoveChildren).
await deleteFileNodes(targets.map((t) => t.id));
await loadFiles('refresh');
} catch (e) {
Alert.alert(t('files.delete_error', 'Failed to delete'), e instanceof Error ? e.message : String(e));
}
}, [confirmDelete, clearSelection, loadFiles, t]);
const sortLabel = t(`files.${sortKey}`, sortKey);
const SortDirIcon = sortDir === 'desc' ? ArrowDown : ArrowUp;
const renderHeader = () => {
const canBack = path.length > 0;
const ToggleIcon = viewMode === 'list' ? LayoutGrid : ListIcon;
if (selectionMode) {
const selectedRows = visibleFiles.filter((r) => selection.has(r.id));
return (
<View style={[styles.header, headerInsets]}>
<View style={styles.headerRow}>
<Pressable
onPress={clearSelection}
hitSlop={8}
style={styles.headerBtn}
accessibilityRole="button"
accessibilityLabel={t('email_list.batch_actions.clear_selection', 'Clear selection')}
>
<X size={22} color={c.text} />
</Pressable>
<Text style={styles.title}>
{t('files.selected_count', '{count, plural, one {# selected} other {# selected}}', { count: selection.size })}
</Text>
<View style={styles.headerActions}>
<Pressable
onPress={() => void downloadSelection(selectedRows)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel={t('files.download', 'Download')}
disabled={batchBusy != null}
>
{batchBusy != null ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<Download size={20} color={c.text} />
)}
</Pressable>
<Pressable
onPress={() => requestDelete(selectedRows)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel={t('files.delete', 'Delete')}
>
<Trash2 size={20} color={c.error} />
</Pressable>
</View>
</View>
</View>
);
}
return (
<View style={[styles.header, headerInsets]}>
<View style={styles.headerRow}>
<View style={styles.headerLeft}>
{canBack ? (
<Pressable
onPress={goBack}
hitSlop={8}
style={styles.headerBtn}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ChevronLeft size={22} color={c.text} />
</Pressable>
) : null}
<Text style={styles.title} numberOfLines={1}>
{headerName}
</Text>
</View>
<View style={styles.headerActions}>
<Pressable
onPress={() => {
setSearchOpen((v) => !v);
if (searchOpen) setSearchQuery('');
}}
hitSlop={8}
style={[styles.headerBtn, searchOpen && styles.headerBtnActive]}
accessibilityLabel={t('files.search_placeholder', 'Search files...')}
>
<Search size={20} color={c.text} />
</Pressable>
{!inSharedSubtree ? (
<Pressable
onPress={() => setNewFolderOpen(true)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel={t('files.new_folder', 'New Folder')}
>
<FolderPlus size={20} color={c.text} />
</Pressable>
) : null}
{!inSharedSubtree ? (
<Pressable
onPress={() => void startUpload()}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel={t('files.upload', 'Upload')}
disabled={uploading}
>
{uploading ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<Upload size={20} color={c.text} />
)}
</Pressable>
) : null}
<Pressable
onPress={toggleView}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel={viewMode === 'list' ? t('files.grid_view', 'Grid view') : t('files.list_view', 'List view')}
>
<ToggleIcon size={20} color={c.text} />
</Pressable>
</View>
</View>
{canBack ? (
<Text style={styles.breadcrumb} numberOfLines={1}>
{[rootLabel, ...path.map((p) => p.name)].join(' / ')}
</Text>
) : null}
<View style={styles.toolbarRow}>
{searchOpen ? (
<TextInput
value={searchQuery}
onChangeText={setSearchQuery}
placeholder={t('files.search_placeholder', 'Search files...')}
placeholderTextColor={c.textMuted}
style={styles.searchInput}
autoFocus
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
) : <View style={{ flex: 1 }} />}
<Pressable
onPress={() => setSortSheetOpen(true)}
hitSlop={6}
style={styles.sortChip}
accessibilityLabel={t('files.settings_default_sort', 'Default Sort')}
>
<ArrowUpDown size={13} color={c.textMuted} />
<Text style={styles.sortChipText}>{sortLabel}</Text>
<SortDirIcon size={12} color={c.textMuted} />
</Pressable>
</View>
{uploadProgress ? (
<View style={styles.progressRow}>
<View style={{ flex: 1 }}>
<Text style={styles.progressText} numberOfLines={1}>
{t('files.uploading_progress', 'Uploading {current}/{total} · {name}', {
current: uploadProgress.current,
total: uploadProgress.total,
name: uploadProgress.name,
})}
</Text>
<View style={styles.progressTrack}>
<View style={[styles.progressBar, { width: `${Math.round(uploadProgress.percent * 100)}%` }]} />
</View>
</View>
<Text style={styles.progressPercent}>{Math.round(uploadProgress.percent * 100)}%</Text>
<Pressable onPress={cancelUpload} hitSlop={8} accessibilityLabel={t('files.cancel', 'Cancel')}>
<X size={18} color={c.text} />
</Pressable>
</View>
) : null}
</View>
);
};
const renderIcon = (item: FileRow, size: number) => {
const isDir = isFolder(item);
const thumb = !isDir && showThumbnails ? thumbnailSource(item) : null;
if (thumb) {
return (
<Image
source={thumb}
style={{ width: size, height: size, borderRadius: radius.sm, backgroundColor: c.muted }}
resizeMode="cover"
accessibilityIgnoresInvertColors
/>
);
}
if (!showIcons) return <View style={{ width: size, height: size }} />;
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
return <Icon size={size} color={tint} />;
};
const renderRow = (item: FileRow) => {
const isDir = isFolder(item);
const selected = selection.has(item.id);
const busy = busyId === item.id;
const sharedOut = !!item.shareWith && Object.keys(item.shareWith).length > 0;
const modified = modifiedOf(item);
return (
<Pressable
onPress={() => handleRowPress(item)}
onLongPress={() => handleRowLongPress(item)}
delayLongPress={300}
style={({ pressed }) => [
styles.fileRow,
selected && { backgroundColor: c.primaryBg },
pressed && !selected && { backgroundColor: c.surfaceHover },
]}
>
{renderIcon(item, showThumbnails && !isDir && isImageName(item.displayName) ? 36 : 20)}
<View style={styles.fileInfo}>
<View style={styles.fileNameRow}>
<Text style={styles.fileName} numberOfLines={1}>
{item.displayName}
</Text>
{item.isShared ? (
<Share2 size={13} color={c.primary} />
) : sharedOut ? (
<Users size={13} color={c.primary} />
) : null}
</View>
<View style={styles.fileMetaRow}>
{item.isShared && item.accountName ? (
<Text style={styles.fileMeta} numberOfLines={1}>
{t('files.shared_by', 'Shared by {name}', { name: item.accountName })}
</Text>
) : null}
{item.size != null && !isDir ? (
<Text style={styles.fileMeta}>{formatFileSize(item.size)}</Text>
) : null}
{modified ? (
<Text style={styles.fileMeta}>{formatModified(modified)}</Text>
) : null}
</View>
</View>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : !selectionMode ? (
<Pressable
onPress={(e) => {
e.stopPropagation();
setActionsTarget(item);
}}
hitSlop={8}
style={styles.rowMore}
accessibilityRole="button"
accessibilityLabel={t('files.context_menu', 'Actions')}
>
<MoreVertical size={18} color={c.textMuted} />
</Pressable>
) : null}
</Pressable>
);
};
const renderGridCell = (item: FileRow) => {
const isDir = isFolder(item);
const selected = selection.has(item.id);
const sharedOut = !!item.shareWith && Object.keys(item.shareWith).length > 0;
return (
<Pressable
onPress={() => handleRowPress(item)}
onLongPress={() => handleRowLongPress(item)}
delayLongPress={300}
style={({ pressed }) => [
styles.gridItem,
selected && { backgroundColor: c.primaryBg },
pressed && !selected && { backgroundColor: c.surfaceHover },
]}
>
{renderIcon(item, showThumbnails && !isDir && isImageName(item.displayName) ? 64 : 36)}
<View style={styles.gridNameRow}>
<Text style={styles.gridName} numberOfLines={2}>
{item.displayName}
</Text>
{item.isShared ? (
<Share2 size={12} color={c.primary} />
) : sharedOut ? (
<Users size={12} color={c.primary} />
) : null}
</View>
{item.isShared && item.accountName ? (
<Text style={styles.gridMeta} numberOfLines={1}>
{t('files.shared_by', 'Shared by {name}', { name: item.accountName })}
</Text>
) : item.size != null && !isDir ? (
<Text style={styles.gridMeta} numberOfLines={1}>
{formatFileSize(item.size)}
</Text>
) : null}
</Pressable>
);
};
let body: React.ReactNode;
if (loading && !refreshing) {
body = (
<View style={styles.center}>
<ActivityIndicator color={c.primary} />
</View>
);
} else if (error) {
body = (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => void loadFiles()}
>
<Text style={styles.retryText}>{t('files.retry', 'Retry')}</Text>
</TouchableOpacity>
</View>
);
} else if (visibleFiles.length === 0) {
body = (
<View style={styles.center}>
<HardDrive size={48} color={c.textMuted} />
<Text style={styles.emptyText}>
{searchQuery.trim()
? t('files.no_results', 'No files match your search')
: path.length > 0
? t('files.folder_empty', 'This folder is empty')
: t('files.empty_state_title', 'No files yet')}
</Text>
</View>
);
} else if (viewMode === 'grid') {
body = (
<FlatList
data={visibleFiles}
// A new column count needs a fresh list.
key={`grid-${grid.columns}`}
numColumns={grid.columns}
keyExtractor={(item) => item.id}
columnWrapperStyle={styles.gridRow}
contentContainerStyle={styles.gridContent}
onLayout={(e) => setGridWidth(Math.round(e.nativeEvent.layout.width))}
renderItem={({ item }) => <View style={{ width: grid.cellWidth }}>{renderGridCell(item)}</View>}
keyboardShouldPersistTaps="handled"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
}
/>
);
} else {
body = (
<FlatList
data={visibleFiles}
key="list"
keyExtractor={(item) => item.id}
renderItem={({ item }) => renderRow(item)}
keyboardShouldPersistTaps="handled"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
}
/>
);
}
// On a wide window an open file shows beside the list instead of covering it.
const previewDocked = isSplit && previewTarget != null;
return (
<View style={[styles.container, previewDocked && styles.splitRow, { paddingLeft: insets.left, paddingRight: insets.right }]}>
<View style={styles.listColumn}>
{renderHeader()}
{body}
</View>
{previewDocked && (
<View style={styles.previewPane}>
<FilePreviewModal docked file={previewTarget} onClose={() => setPreviewTarget(null)} />
</View>
)}
<PromptModal
visible={newFolderOpen}
title={t('files.new_folder', 'New Folder')}
placeholder={t('files.new_folder_name', 'Folder name')}
confirmText={t('files.create', 'Create')}
cancelText={t('files.cancel', 'Cancel')}
value={newFolderName}
onChange={setNewFolderName}
onCancel={() => {
setNewFolderOpen(false);
setNewFolderName('');
}}
onSubmit={submitNewFolder}
/>
<PromptModal
visible={renameTarget != null}
title={t('files.rename_title', 'Rename')}
placeholder={t('files.new_name', 'New name')}
confirmText={t('files.rename', 'Rename')}
cancelText={t('files.cancel', 'Cancel')}
value={renameValue}
onChange={setRenameValue}
onCancel={() => {
setRenameTarget(null);
setRenameValue('');
}}
onSubmit={submitRename}
/>
<ActionsSheet
target={actionsTarget}
sharingEnabled={sharingEnabled}
t={t}
onClose={() => setActionsTarget(null)}
onPreview={(r) => {
setActionsTarget(null);
setPreviewTarget(r);
}}
onOpenWith={(r) => {
setActionsTarget(null);
void openFileWith(r);
}}
onDownload={(r) => {
setActionsTarget(null);
void downloadFile(r);
}}
onShare={(r) => {
setActionsTarget(null);
setShareTarget(r);
}}
onMove={(r) => {
setActionsTarget(null);
setMoveTarget(r);
}}
onDuplicate={(r) => {
setActionsTarget(null);
void duplicateFile(r);
}}
onRename={(r) => {
setActionsTarget(null);
setRenameTarget(r);
setRenameValue(r.displayName);
}}
onDelete={(r) => {
setActionsTarget(null);
requestDelete([r]);
}}
/>
<FolderPickerSheet
target={moveTarget}
allNodes={allNodes}
rootLabel={rootLabel}
t={t}
onClose={() => setMoveTarget(null)}
onPick={(destinationId) => moveTarget && void performMove(moveTarget, destinationId)}
/>
<SortSheet
visible={sortSheetOpen}
sortKey={sortKey}
sortDir={sortDir}
t={t}
onClose={() => setSortSheetOpen(false)}
onChangeKey={(k) => setSetting('filesDefaultSortKey', k)}
onChangeDir={(d) => setSetting('filesDefaultSortDir', d)}
/>
{!isSplit && <FilePreviewModal file={previewTarget} onClose={() => setPreviewTarget(null)} />}
<ShareSheet
node={shareTarget}
onClose={() => setShareTarget(null)}
onChanged={() => void loadFiles('refresh')}
/>
<Dialog
visible={confirmDelete != null}
title={
confirmDelete && confirmDelete.rows.length > 1
? t('files.delete_items_title', '{count, plural, one {Delete # item?} other {Delete # items?}}', { count: confirmDelete.rows.length })
: t('files.delete_item_title', 'Delete this item?')
}
message={t('files.delete_cascade_hint', "Folders are deleted along with everything inside them. This can't be undone.")}
variant="destructive"
confirmText={t('files.delete', 'Delete')}
cancelText={t('files.cancel', 'Cancel')}
onConfirm={() => void performDelete()}
onCancel={() => setConfirmDelete(null)}
/>
</View>
);
}
interface PromptModalProps {
visible: boolean;
title: string;
placeholder: string;
confirmText: string;
cancelText: string;
value: string;
onChange: (v: string) => void;
onCancel: () => void;
onSubmit: () => void;
}
function PromptModal(props: PromptModalProps) {
const c = useColors();
const styles = React.useMemo(() => makePromptStyles(c), [c]);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={props.visible}
transparent
animationType="fade"
onRequestClose={props.onCancel}
>
<TouchableWithoutFeedback onPress={props.onCancel}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<View style={styles.dialog}>
<Text style={styles.title}>{props.title}</Text>
<TextInput
value={props.value}
onChangeText={props.onChange}
placeholder={props.placeholder}
placeholderTextColor={c.textMuted}
style={styles.input}
autoFocus
returnKeyType="done"
onSubmitEditing={props.onSubmit}
/>
<View style={styles.row}>
<Pressable onPress={props.onCancel} style={styles.btnGhost}>
<Text style={styles.btnGhostText}>{props.cancelText}</Text>
</Pressable>
<Pressable
onPress={props.onSubmit}
style={[styles.btnPrimary, !props.value.trim() && { opacity: 0.5 }]}
disabled={!props.value.trim()}
>
<Text style={styles.btnPrimaryText}>{props.confirmText}</Text>
</Pressable>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
interface ActionsSheetProps {
target: FileRow | null;
sharingEnabled: boolean;
t: Translate;
onClose: () => void;
onPreview: (r: FileRow) => void;
onOpenWith: (r: FileRow) => void;
onDownload: (r: FileRow) => void;
onShare: (r: FileRow) => void;
onMove: (r: FileRow) => void;
onDuplicate: (r: FileRow) => void;
onRename: (r: FileRow) => void;
onDelete: (r: FileRow) => void;
}
function ActionsSheet({
target, sharingEnabled, t, onClose, onPreview, onOpenWith, onDownload, onShare, onMove, onDuplicate, onRename, onDelete,
}: ActionsSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const formatModified = useFormatModified();
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
if (!target) return null;
const isDir = isFolder(target);
// Owned nodes report full rights (or no myRights at all); shared-with-me
// nodes are managed by their owner, and write ops would have to route to
// the owner's account — so they only get preview/download here.
const canShare = sharingEnabled && !target.isShared && (target.myRights?.mayShare ?? true);
const modified = modifiedOf(target);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{target.displayName}
</Text>
<Text style={styles.sheetMeta} numberOfLines={1}>
{[
isDir ? t('files.folder', 'Folder') : (target.type || t('files.file', 'File')),
!isDir && target.size != null ? formatFileSize(target.size) : null,
modified ? formatModified(modified) : null,
].filter(Boolean).join(' · ')}
</Text>
{!isDir && canPreviewInApp(target) ? (
<Pressable style={styles.action} onPress={() => onPreview(target)}>
<Eye size={18} color={c.text} />
<Text style={styles.actionLabel}>{t('files.preview', 'Preview')}</Text>
</Pressable>
) : null}
{!isDir ? (
<Pressable style={styles.action} onPress={() => onOpenWith(target)}>
<ExternalLink size={18} color={c.text} />
<Text style={styles.actionLabel}>{t('files.open_with', 'Open with…')}</Text>
</Pressable>
) : null}
{!isDir ? (
<Pressable style={styles.action} onPress={() => onDownload(target)}>
<Download size={18} color={c.text} />
<Text style={styles.actionLabel}>{t('files.download', 'Download')}</Text>
</Pressable>
) : null}
{canShare ? (
<Pressable style={styles.action} onPress={() => onShare(target)}>
<Users size={18} color={c.text} />
<Text style={styles.actionLabel}>{t('files.sharing_access', 'Sharing & access')}</Text>
</Pressable>
) : null}
{!target.isShared ? (
<Pressable style={styles.action} onPress={() => onMove(target)}>
<FolderInput size={18} color={c.text} />
<Text style={styles.actionLabel}>{t('files.move_to', 'Move to…')}</Text>
</Pressable>
) : null}
{!target.isShared && !isDir ? (
<Pressable style={styles.action} onPress={() => onDuplicate(target)}>
<Copy size={18} color={c.text} />
<Text style={styles.actionLabel}>{t('files.duplicate', 'Duplicate')}</Text>
</Pressable>
) : null}
{!target.isShared ? (
<Pressable style={styles.action} onPress={() => onRename(target)}>
<Pencil size={18} color={c.text} />
<Text style={styles.actionLabel}>{t('files.rename', 'Rename')}</Text>
</Pressable>
) : null}
{!target.isShared ? (
<Pressable style={styles.action} onPress={() => onDelete(target)}>
<Trash2 size={18} color={c.error} />
<Text style={[styles.actionLabel, { color: c.error }]}>{t('files.delete', 'Delete')}</Text>
</Pressable>
) : null}
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
interface FolderPickerSheetProps {
target: FileRow | null;
allNodes: FileNode[];
rootLabel: string;
t: Translate;
onClose: () => void;
onPick: (destinationId: string | null) => void;
}
// "Move to…" destination picker: every own folder except the node itself and
// its descendants (a folder can't be moved into itself), flattened with
// indentation, plus the drive root.
function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }: FolderPickerSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
const options = useMemo(() => {
if (!target) return [];
const excluded = new Set<string>([target.id]);
// Descendants of the moved node.
let grew = true;
while (grew) {
grew = false;
for (const n of allNodes) {
if (n.parentId && excluded.has(n.parentId) && !excluded.has(n.id)) {
excluded.add(n.id);
grew = true;
}
}
}
const folders = allNodes.filter((n) => isFolder(n) && !n.isShared && !excluded.has(n.id));
const byParent = new Map<string | null, FileNode[]>();
for (const f of folders) {
const key = f.parentId ?? null;
if (!byParent.has(key)) byParent.set(key, []);
byParent.get(key)!.push(f);
}
const out: { id: string | null; name: string; depth: number }[] = [{ id: null, name: rootLabel, depth: 0 }];
const walk = (parentId: string | null, depth: number) => {
const kids = (byParent.get(parentId) ?? []).sort((a, b) => a.name.localeCompare(b.name));
for (const k of kids) {
out.push({ id: k.id, name: k.name, depth });
walk(k.id, depth + 1);
}
};
walk(null, 1);
return out;
}, [target, allNodes, rootLabel]);
if (!target) return null;
const currentParent = target.parentId ?? null;
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{t('files.move_to', 'Move to…')} · {target.displayName}
</Text>
<ScrollView style={{ maxHeight: 360 }}>
{options.map((opt) => {
const isCurrent = opt.id === currentParent;
return (
<Pressable
key={opt.id ?? '__root__'}
style={[styles.action, { paddingLeft: spacing.md + opt.depth * spacing.lg }]}
onPress={() => onPick(opt.id)}
disabled={isCurrent}
>
{opt.id === null ? (
<HardDrive size={18} color={c.text} />
) : (
<Folder size={18} color={c.calendar.blue} />
)}
<Text style={[styles.actionLabel, isCurrent && { color: c.textMuted }]} numberOfLines={1}>
{opt.name}
</Text>
{isCurrent ? <Check size={16} color={c.textMuted} /> : null}
</Pressable>
);
})}
</ScrollView>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
interface SortSheetProps {
visible: boolean;
sortKey: FilesSortKey;
sortDir: FilesSortDir;
t: Translate;
onClose: () => void;
onChangeKey: (k: FilesSortKey) => void;
onChangeDir: (d: FilesSortDir) => void;
}
function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChangeDir }: SortSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
if (!visible) return null;
const keys: FilesSortKey[] = ['name', 'size', 'modified'];
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.sheetTitle}>{t('files.settings_default_sort', 'Sort')}</Text>
{keys.map((k) => (
<Pressable key={k} style={styles.action} onPress={() => { onChangeKey(k); onClose(); }}>
<Text style={[styles.actionLabel, sortKey === k && { color: c.primary, fontWeight: '600' }]}>
{t(`files.${k}`, k)}
</Text>
{sortKey === k ? <Check size={16} color={c.primary} /> : null}
</Pressable>
))}
<View style={styles.sheetDivider} />
{(['asc', 'desc'] as FilesSortDir[]).map((d) => (
<Pressable key={d} style={styles.action} onPress={() => { onChangeDir(d); onClose(); }}>
{d === 'asc' ? <ArrowUp size={18} color={c.text} /> : <ArrowDown size={18} color={c.text} />}
<Text style={[styles.actionLabel, sortDir === d && { color: c.primary, fontWeight: '600' }]}>
{d === 'asc'
? t('files.settings_ascending', 'Ascending')
: t('files.settings_descending', 'Descending')}
</Text>
{sortDir === d ? <Check size={16} color={c.primary} /> : null}
</Pressable>
))}
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: c.background,
},
splitRow: { flexDirection: 'row' },
listColumn: { flex: 1 },
previewPane: { width: '45%', borderLeftWidth: 1, borderLeftColor: c.border },
header: {
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
},
headerLeft: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerBtn: {
minWidth: componentSizes.buttonLg,
minHeight: componentSizes.buttonLg,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
headerBtnActive: {
backgroundColor: c.primaryBg,
},
// Same header title as Mail and Contacts (text-lg font-semibold).
title: {
...typography.h3,
color: c.text,
flexShrink: 1,
},
breadcrumb: {
marginTop: 2,
fontSize: typography.caption.fontSize,
color: c.textMuted,
},
toolbarRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginTop: spacing.sm,
},
searchInput: {
flex: 1,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: 6,
color: c.text,
fontSize: typography.body.fontSize,
backgroundColor: c.surface,
},
sortChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
borderWidth: 1,
borderColor: c.border,
},
sortChipText: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
},
progressRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginTop: spacing.sm,
},
progressText: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
marginBottom: 4,
},
progressTrack: {
height: 4,
borderRadius: 2,
backgroundColor: c.muted,
overflow: 'hidden',
},
progressBar: {
height: 4,
backgroundColor: c.primary,
},
progressPercent: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
minWidth: 36,
textAlign: 'right',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: spacing.sm,
},
emptyText: {
color: c.textMuted,
fontSize: typography.body.fontSize,
marginTop: spacing.sm,
},
errorText: {
color: c.error,
fontSize: typography.body.fontSize,
textAlign: 'center',
paddingHorizontal: spacing.lg,
},
retryButton: {
marginTop: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: c.primary,
borderRadius: 8,
},
retryText: {
color: c.primaryForeground,
fontWeight: '600' as const,
},
fileRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderBottomWidth: 1,
borderBottomColor: c.border,
gap: spacing.sm,
},
fileInfo: {
flex: 1,
},
fileNameRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
fileName: {
fontSize: typography.body.fontSize,
color: c.text,
flexShrink: 1,
},
fileMetaRow: {
flexDirection: 'row',
gap: spacing.sm,
marginTop: 2,
},
fileMeta: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
},
rowMore: {
width: componentSizes.buttonLg,
height: componentSizes.buttonLg,
alignItems: 'center',
justifyContent: 'center',
},
gridContent: {
padding: spacing.sm,
},
gridRow: {
gap: spacing.sm,
},
gridItem: {
alignItems: 'center',
gap: spacing.xs,
paddingVertical: spacing.md,
paddingHorizontal: spacing.xs,
borderRadius: radius.md,
},
gridNameRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
maxWidth: '100%',
},
gridName: {
fontSize: typography.caption.fontSize,
color: c.text,
textAlign: 'center',
flexShrink: 1,
},
gridMeta: {
fontSize: typography.small.fontSize,
color: c.textMuted,
textAlign: 'center',
},
});
}
function makePromptStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
dialog: {
width: '100%',
maxWidth: 400,
backgroundColor: c.background,
borderRadius: radius.lg,
padding: spacing.lg,
gap: spacing.md,
},
title: {
...typography.h3,
color: c.text,
},
input: {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: c.text,
fontSize: typography.body.fontSize,
backgroundColor: c.surface,
},
row: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
},
btnGhost: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
},
btnGhostText: {
color: c.textMuted,
fontWeight: '500' as const,
},
btnPrimary: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
backgroundColor: c.primary,
},
btnPrimaryText: {
color: c.primaryForeground,
fontWeight: '600' as const,
},
});
}
function makeSheetStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
paddingTop: spacing.md,
paddingBottom: spacing.xxl,
paddingHorizontal: spacing.sm,
},
sheetTitle: {
...typography.h3,
color: c.text,
paddingHorizontal: spacing.md,
marginBottom: 2,
},
sheetMeta: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
paddingHorizontal: spacing.md,
marginBottom: spacing.sm,
},
sheetDivider: {
height: 1,
backgroundColor: c.border,
marginVertical: spacing.xs,
},
action: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
},
actionLabel: {
fontSize: typography.body.fontSize,
color: c.text,
flex: 1,
},
});
}