From 1858d08dd055b4dbb4c249de72b6939525bcf261 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 14 May 2026 12:57:03 +0200 Subject: [PATCH] feat: enhance notification handling with accountId support and improve file management functions --- App.tsx | 19 +- .../bulwarkmobile/BulwarkFcmModule.kt | 5 +- .../bulwarkmobile/NotificationTapStore.kt | 12 +- src/api/files.ts | 105 +++++++-- src/lib/push-background-task.ts | 2 + src/lib/push-notifications.ts | 4 + src/screens/FilesScreen.tsx | 201 +++++++++--------- 7 files changed, 226 insertions(+), 122 deletions(-) diff --git a/App.tsx b/App.tsx index 3462842f..fd1b5463 100644 --- a/App.tsx +++ b/App.tsx @@ -51,8 +51,21 @@ const Stack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); const navigationRef = createNavigationContainerRef(); -function navigateToNotificationTap(payload: NotificationTapPayload): void { +async function navigateToNotificationTap(payload: NotificationTapPayload): Promise { if (!navigationRef.isReady()) return; + + // The notification carries the account it was generated for. If the user + // has since switched to a different account (or had a different one active + // when the notification arrived), opening EmailThread under the active + // account would fetch the email from the wrong server and fail. + const auth = useAuthStore.getState(); + if (payload.accountId && payload.accountId !== auth.activeAccountId) { + const account = useAccountStore.getState().getAccountById(payload.accountId); + if (!account) return; // account was logged out — nothing safe to open. + await auth.switchAccount(payload.accountId); + if (useAuthStore.getState().activeAccountId !== payload.accountId) return; + } + navigationRef.navigate('EmailThread', { emailId: payload.emailId, threadId: payload.threadId, @@ -261,11 +274,11 @@ export default function App() { void (async () => { const initial = await getInitialNotificationTap(); if (cancelled || !initial) return; - navigateToNotificationTap(initial); + await navigateToNotificationTap(initial); })(); const unsubscribe = addNotificationTapListener((payload) => { - navigateToNotificationTap(payload); + void navigateToNotificationTap(payload); }); return () => { diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt index 160cc9cc..f772b5b9 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt @@ -65,6 +65,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) val emailId = options.getString("emailId") val threadId = options.getString("threadId") val subject = options.takeIf { it.hasKey("subject") }?.getString("subject") + val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId") // Bitmap fetch + draw off the bridge thread so the caller doesn't // block waiting for the favicon request. @@ -73,7 +74,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) ?: makeLetterAvatar(initials, bgColorHex) postNotification( notificationId, title, body, largeIcon, bgColorHex, - emailId, threadId, subject, + emailId, threadId, subject, accountId, ) promise.resolve(null) } @@ -94,6 +95,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) emailId: String?, threadId: String?, subject: String?, + accountId: String?, ) { val ctx = reactApplicationContext val intent = Intent(ctx, MainActivity::class.java).apply { @@ -101,6 +103,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId) if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId) if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject) + if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) } val pending = PendingIntent.getActivity( ctx, diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt index 13828893..6c1595be 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt @@ -23,6 +23,11 @@ object NotificationTapStore { // our notification builder writes — preventing other apps from // navigating us to a forged email/thread. private val ID_PATTERN = Regex("^[A-Za-z0-9_-]{1,128}$") + // accountId is `username@host` (see generateAccountId), so it needs `.`, + // `@`, `+` etc. — cap length and restrict charset to what that helper can + // emit. accountId is optional: notifications created before this field + // existed still navigate (using the active account as a fallback). + private val ACCOUNT_ID_PATTERN = Regex("^[A-Za-z0-9._+@-]{1,256}$") fun captureFromIntent(intent: Intent?): TapPayload? { val extras = intent?.extras ?: return null @@ -34,12 +39,14 @@ object NotificationTapStore { // cap its length so a hostile launcher can't ship a 1MB string into // the navigation payload. val subject = extras.getString(EXTRA_SUBJECT)?.take(512) - val payload = TapPayload(emailId, threadId, subject) + val accountId = extras.getString(EXTRA_ACCOUNT_ID)?.takeIf { ACCOUNT_ID_PATTERN.matches(it) } + val payload = TapPayload(emailId, threadId, subject, accountId) pending = payload // Clear so a subsequent activity lifecycle event doesn't replay this. extras.remove(EXTRA_EMAIL_ID) extras.remove(EXTRA_THREAD_ID) extras.remove(EXTRA_SUBJECT) + extras.remove(EXTRA_ACCOUNT_ID) return payload } @@ -47,15 +54,18 @@ object NotificationTapStore { val emailId: String, val threadId: String, val subject: String?, + val accountId: String?, ) { fun toMap(): WritableMap = Arguments.createMap().apply { putString("emailId", emailId) putString("threadId", threadId) if (subject != null) putString("subject", subject) + if (accountId != null) putString("accountId", accountId) } } const val EXTRA_EMAIL_ID = "bulwark.notification.emailId" const val EXTRA_THREAD_ID = "bulwark.notification.threadId" const val EXTRA_SUBJECT = "bulwark.notification.subject" + const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId" } diff --git a/src/api/files.ts b/src/api/files.ts index 721b1c54..5c252905 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -21,13 +21,39 @@ export function isDirectory(node: Pick): boolean { return DIRECTORY_TYPES.has(t) || t.includes('directory'); } -// Stalwart's FileNode/get does not support a parentId filter — fetching with -// `ids: null` returns the whole tree, which the caller filters client-side. -// Mirrors the webmail file-store; keeping the API uniform avoids a divergence -// later when we add other JMAP servers. -// -// Properties are listed explicitly: some Stalwart builds omit `type` from the -// default property set, which makes `isDirectory()` impossible to compute. +// Stalwart rejects `/` in FileNode names, so the webmail (and now this app) +// uses U+2215 DIVISION SLASH as the path separator inside the flat `name` +// field. Hierarchy is encoded into the name — there is no `parentId` nesting. +// e.g. a file inside the `Documents` folder is stored with +// `name = "Documents∕report.pdf"`. The display name shown to the user +// strips the prefix. +export const PATH_SEP = '∕'; + +// Path is a list of folder display names from root. [] is root, +// ['Documents'] is inside Documents, ['Documents','sub'] is inside the +// `sub` folder under Documents. +export function pathToPrefix(path: readonly string[]): string { + if (path.length === 0) return ''; + return path.join(PATH_SEP) + PATH_SEP; +} + +// True when `node` is a direct child (not a descendant) of `prefix`. +export function isDirectChildOfPrefix(node: Pick, prefix: string): boolean { + if (!node.name) return false; + if (prefix === '') return !node.name.includes(PATH_SEP); + if (!node.name.startsWith(prefix)) return false; + const rest = node.name.slice(prefix.length); + return rest.length > 0 && !rest.includes(PATH_SEP); +} + +export function nodeDisplayName(node: Pick, prefix: string): string { + return prefix && node.name.startsWith(prefix) ? node.name.slice(prefix.length) : node.name; +} + +// Stalwart's FileNode/get does not support a parentId filter and some builds +// omit `type` from the default property set. We fetch the whole tree and +// filter client-side; properties are listed explicitly so `isDirectory()` +// always has a value to look at. const FILE_NODE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated']; export async function getAllFileNodes(): Promise { @@ -59,16 +85,15 @@ export async function createFileNode( } export async function createFolder( - name: string, - parentId: string | null, + displayName: string, + currentPath: readonly string[], ): Promise { // Stalwart's FileNode/set rejects directory creates without a blobId // ("Missing blob id"), so we upload an empty blob first and reference it. // Mirrors the webmail's createFileDirectory. const blob = await uploadBytes(new Uint8Array(0), 'application/x-directory'); - const props: Partial = { name, type: 'd', blobId: blob.blobId, size: 0 }; - if (parentId !== null) props.parentId = parentId; - return createFileNode(props); + const fullName = pathToPrefix(currentPath) + displayName; + return createFileNode({ name: fullName, type: 'd', blobId: blob.blobId, size: 0 }); } export async function updateFileNode( @@ -84,6 +109,33 @@ export async function updateFileNode( if (notUpdated) throw new Error(notUpdated.description || 'Update failed'); } +// Rename a node and, if it's a folder, cascade the rename to every descendant +// so the encoded path stays consistent. The webmail does this same dance. +export async function renameFileNode( + node: FileNode, + newDisplayName: string, + currentPath: readonly string[], + allNodes: readonly FileNode[], +): Promise { + const prefix = pathToPrefix(currentPath); + const oldServerName = node.name; + const newServerName = prefix + newDisplayName; + if (oldServerName === newServerName) return; + + await updateFileNode(node.id, { name: newServerName }); + + if (isDirectory(node)) { + const oldFolderPrefix = oldServerName + PATH_SEP; + const newFolderPrefix = newServerName + PATH_SEP; + for (const n of allNodes) { + if (n.id === node.id) continue; + if (n.name.startsWith(oldFolderPrefix)) { + await updateFileNode(n.id, { name: newFolderPrefix + n.name.slice(oldFolderPrefix.length) }); + } + } + } +} + export async function deleteFileNodes(ids: string[]): Promise { if (ids.length === 0) return; const accountId = jmapClient.accountId; @@ -93,6 +145,27 @@ export async function deleteFileNodes(ids: string[]): Promise { ); } +// Delete `targets` and every descendant. Stalwart doesn't enforce parent +// references, so we collect descendants by name-prefix and destroy them all +// in one call. Without this, deleting a folder leaves its files orphaned and +// still discoverable at the old encoded path. +export async function deleteWithDescendants( + targets: readonly FileNode[], + allNodes: readonly FileNode[], +): Promise { + const ids = new Set(); + for (const node of targets) { + ids.add(node.id); + if (isDirectory(node)) { + const folderPrefix = node.name + PATH_SEP; + for (const n of allNodes) { + if (n.name.startsWith(folderPrefix)) ids.add(n.id); + } + } + } + await deleteFileNodes(Array.from(ids)); +} + export function getFileNodeDownloadUrl(node: FileNode): string { if (!node.blobId) throw new Error('Folders cannot be downloaded'); return getDownloadUrl(node.blobId, node.name, node.type); @@ -100,14 +173,14 @@ export function getFileNodeDownloadUrl(node: FileNode): string { export async function uploadFileNode( uri: string, - name: string, + displayName: string, mimeType: string, - parentId: string | null, + currentPath: readonly string[], ): Promise { const blob = await uploadBlob(uri, mimeType); + const fullName = pathToPrefix(currentPath) + displayName; return createFileNode({ - name, - parentId, + name: fullName, type: blob.type || mimeType || 'application/octet-stream', blobId: blob.blobId, size: blob.size, diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index 8cb9539c..4b8655b1 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -41,6 +41,7 @@ interface ShowNotificationOptions { emailId: string; threadId: string; subject?: string; + accountId: string; } interface BulwarkFcmNative { @@ -100,6 +101,7 @@ export async function pushBackgroundTask(_data: unknown): Promise { emailId, threadId: email.threadId, subject: email.subject ?? undefined, + accountId, }); await AsyncStorage.setItem(LAST_NOTIFIED_EMAIL_ID_KEY, emailId); diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 51cc6e47..f73a03fb 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -327,6 +327,10 @@ export interface NotificationTapPayload { emailId: string; threadId: string; subject?: string; + // Identifies which logged-in account the notification was generated for. + // Optional for back-compat: older notifications already on the system tray + // won't carry this and will fall back to the active account on tap. + accountId?: string; } // Returns - and clears - any pending "notification tap" that launched the app diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index a0b927e0..2279ddec 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -18,6 +18,7 @@ import { FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon, MoreVertical, Pencil, Share2, Trash2, Upload, X, Download, } from 'lucide-react-native'; + // 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 @@ -37,7 +38,8 @@ function loadDocumentPicker(): DocumentPickerModule | null { } import { - createFolder, deleteFileNodes, getAllFileNodes, isDirectory, updateFileNode, + createFolder, deleteWithDescendants, getAllFileNodes, isDirectChildOfPrefix, + isDirectory, nodeDisplayName, pathToPrefix, PATH_SEP, renameFileNode, uploadFileNode, } from '../api/files'; import { downloadAttachment, shareAttachment } from '../lib/email-export'; @@ -47,13 +49,12 @@ import { useColors } from '../theme/colors'; import { useSettingsStore, type FilesViewMode } from '../stores/settings-store'; import Dialog from '../components/Dialog'; -interface FolderFrame { - id: string | null; - name: string; +// A file row carries the resolved display name alongside the original +// server-side name (the path-encoded `node.name` field). +interface FileRow extends FileNode { + displayName: string; } -const ROOT_FRAME: FolderFrame = { id: null, name: 'Files' }; - function pickFileIcon(name: string) { const ext = name.split('.').pop()?.toLowerCase() ?? ''; if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return FileImage; @@ -77,10 +78,6 @@ function fileIconColor(name: string, c: ThemePalette): string { return c.textMuted; } -function isHidden(node: FileNode): boolean { - return node.name.startsWith('.'); -} - function formatFileSize(bytes?: number): string { if (bytes == null) return ''; if (bytes < 1024) return `${bytes} B`; @@ -111,7 +108,7 @@ export default function FilesScreen() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const [stack, setStack] = useState([ROOT_FRAME]); + const [path, setPath] = useState([]); const [allNodes, setAllNodes] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -121,12 +118,12 @@ export default function FilesScreen() { const [busyId, setBusyId] = useState(null); const [uploading, setUploading] = useState(false); - const [confirmDelete, setConfirmDelete] = useState<{ ids: string[] } | null>(null); + const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[] } | null>(null); const [newFolderOpen, setNewFolderOpen] = useState(false); const [newFolderName, setNewFolderName] = useState(''); - const [renameTarget, setRenameTarget] = useState(null); + const [renameTarget, setRenameTarget] = useState(null); const [renameValue, setRenameValue] = useState(''); - const [actionsTarget, setActionsTarget] = useState(null); + const [actionsTarget, setActionsTarget] = useState(null); const showIcons = useSettingsStore((s) => s.filesShowIcons); const coloredIcons = useSettingsStore((s) => s.filesColoredIcons); @@ -139,8 +136,9 @@ export default function FilesScreen() { const [viewOverride, setViewOverride] = useState(null); const viewMode: FilesViewMode = viewOverride ?? defaultViewMode; - const current = stack[stack.length - 1]; const selectionMode = selection.size > 0; + const prefix = useMemo(() => pathToPrefix(path), [path]); + const headerName = path.length === 0 ? 'Files' : path[path.length - 1]; const loadFiles = useCallback( async (mode: 'initial' | 'refresh' = 'initial') => { @@ -164,26 +162,31 @@ export default function FilesScreen() { void loadFiles(); }, [loadFiles]); - // If we navigated into a folder that has since been deleted, walk back up - // until we land on a still-existing one (or root). + // If we navigated into a folder that no longer exists (it was deleted, or + // we never had it), walk back up until we land on one that does. The check + // looks for a directory node whose serverName matches the encoded path. useEffect(() => { if (allNodes.length === 0) return; - setStack((prev) => { - const ids = new Set(allNodes.map((n) => n.id)); - let cut = prev.length; - for (let i = prev.length - 1; i > 0; i--) { - const id = prev[i].id; - if (id != null && !ids.has(id)) cut = i; + setPath((prev) => { + let next = prev; + while (next.length > 0) { + const parentPrefix = pathToPrefix(next.slice(0, -1)); + const expectedName = parentPrefix + next[next.length - 1]; + const exists = allNodes.some((n) => n.name === expectedName && isDirectory(n)); + if (exists) break; + next = next.slice(0, -1); } - return cut === prev.length ? prev : prev.slice(0, cut); + return next.length === prev.length ? prev : next; }); }, [allNodes]); - const visibleFiles = useMemo(() => { - let out = allNodes.filter((n) => (n.parentId ?? null) === current.id); - if (!showHiddenFiles) out = out.filter((f) => !isHidden(f)); + const visibleFiles = useMemo(() => { + const rows = allNodes + .filter((n) => isDirectChildOfPrefix(n, prefix)) + .map((n) => ({ ...n, displayName: nodeDisplayName(n, prefix) })) + .filter((r) => showHiddenFiles || !r.displayName.startsWith('.')); const dir = sortDir === 'desc' ? -1 : 1; - return [...out].sort((a, b) => { + return rows.sort((a, b) => { const aDir = isDirectory(a) ? 0 : 1; const bDir = isDirectory(b) ? 0 : 1; if (aDir !== bDir) return aDir - bDir; @@ -197,10 +200,10 @@ export default function FilesScreen() { } case 'name': default: - return a.name.localeCompare(b.name) * dir; + return a.displayName.localeCompare(b.displayName) * dir; } }); - }, [allNodes, current.id, showHiddenFiles, sortKey, sortDir]); + }, [allNodes, prefix, showHiddenFiles, sortKey, sortDir]); const clearSelection = useCallback(() => setSelection(new Set()), []); @@ -213,11 +216,11 @@ export default function FilesScreen() { }); }, []); - const previewFile = useCallback(async (node: FileNode) => { - if (!node.blobId) return; - setBusyId(node.id); + const previewFile = useCallback(async (row: FileRow) => { + if (!row.blobId) return; + setBusyId(row.id); try { - await shareAttachment(node.blobId, node.name, node.type); + await shareAttachment(row.blobId, row.displayName, row.type); } catch (e) { Alert.alert('Preview failed', e instanceof Error ? e.message : String(e)); } finally { @@ -225,11 +228,11 @@ export default function FilesScreen() { } }, []); - const downloadFile = useCallback(async (node: FileNode) => { - if (!node.blobId) return; - setBusyId(node.id); + const downloadFile = useCallback(async (row: FileRow) => { + if (!row.blobId) return; + setBusyId(row.id); try { - await downloadAttachment(node.blobId, node.name, node.type); + await downloadAttachment(row.blobId, row.displayName, row.type); } catch (e) { Alert.alert('Download failed', e instanceof Error ? e.message : String(e)); } finally { @@ -238,26 +241,26 @@ export default function FilesScreen() { }, []); const handleRowPress = useCallback( - (node: FileNode) => { + (row: FileRow) => { if (selectionMode) { - toggleSelect(node.id); + toggleSelect(row.id); return; } - if (isDirectory(node)) { - setStack((s) => [...s, { id: node.id, name: node.name }]); + if (isDirectory(row)) { + setPath((p) => [...p, row.displayName]); return; } - void previewFile(node); + void previewFile(row); }, [selectionMode, toggleSelect, previewFile], ); - const handleRowLongPress = useCallback((node: FileNode) => { - toggleSelect(node.id); + const handleRowLongPress = useCallback((row: FileRow) => { + toggleSelect(row.id); }, [toggleSelect]); const goBack = useCallback(() => { - setStack((s) => (s.length > 1 ? s.slice(0, -1) : s)); + setPath((p) => (p.length > 0 ? p.slice(0, -1) : p)); clearSelection(); }, [clearSelection]); @@ -274,33 +277,41 @@ export default function FilesScreen() { const submitNewFolder = useCallback(async () => { const name = newFolderName.trim(); if (!name) return; + if (name.includes(PATH_SEP) || name.includes('/')) { + Alert.alert('Invalid name', 'Folder names cannot contain "/".'); + return; + } setNewFolderOpen(false); setNewFolderName(''); try { - await createFolder(name, current.id); + await createFolder(name, path); await loadFiles('refresh'); } catch (e) { Alert.alert('Create folder failed', e instanceof Error ? e.message : String(e)); } - }, [newFolderName, current.id, loadFiles]); + }, [newFolderName, path, loadFiles]); const submitRename = useCallback(async () => { if (!renameTarget) return; const name = renameValue.trim(); - if (!name || name === renameTarget.name) { + if (!name || name === renameTarget.displayName) { setRenameTarget(null); return; } + if (name.includes(PATH_SEP) || name.includes('/')) { + Alert.alert('Invalid name', 'Names cannot contain "/".'); + return; + } const target = renameTarget; setRenameTarget(null); setRenameValue(''); try { - await updateFileNode(target.id, { name }); + await renameFileNode(target, name, path, allNodes); await loadFiles('refresh'); } catch (e) { Alert.alert('Rename failed', e instanceof Error ? e.message : String(e)); } - }, [renameTarget, renameValue, loadFiles]); + }, [renameTarget, renameValue, path, allNodes, loadFiles]); const startUpload = useCallback(async () => { if (uploading) return; @@ -330,7 +341,7 @@ export default function FilesScreen() { asset.uri, asset.name, asset.mimeType || 'application/octet-stream', - current.id, + path, ); await loadFiles('refresh'); } catch (e) { @@ -338,33 +349,20 @@ export default function FilesScreen() { } finally { setUploading(false); } - }, [uploading, current.id, loadFiles]); + }, [uploading, path, loadFiles]); - const requestDelete = useCallback((ids: string[]) => { - if (ids.length === 0) return; - setConfirmDelete({ ids }); + const requestDelete = useCallback((rows: FileRow[]) => { + if (rows.length === 0) return; + setConfirmDelete({ rows }); }, []); const performDelete = useCallback(async () => { if (!confirmDelete) return; - // Cascade: if the user deletes a folder, also delete every descendant - // we know about. Stalwart deletes folders with content silently otherwise. - const targetIds = new Set(confirmDelete.ids); - let frontier = [...confirmDelete.ids]; - while (frontier.length > 0) { - const next: string[] = []; - for (const node of allNodes) { - if (node.parentId && frontier.includes(node.parentId) && !targetIds.has(node.id)) { - targetIds.add(node.id); - next.push(node.id); - } - } - frontier = next; - } + const targets = confirmDelete.rows; setConfirmDelete(null); clearSelection(); try { - await deleteFileNodes(Array.from(targetIds)); + await deleteWithDescendants(targets, allNodes); await loadFiles('refresh'); } catch (e) { Alert.alert('Delete failed', e instanceof Error ? e.message : String(e)); @@ -372,9 +370,10 @@ export default function FilesScreen() { }, [confirmDelete, allNodes, clearSelection, loadFiles]); const renderHeader = () => { - const canBack = stack.length > 1; + const canBack = path.length > 0; const ToggleIcon = viewMode === 'list' ? LayoutGrid : ListIcon; if (selectionMode) { + const selectedRows = visibleFiles.filter((r) => selection.has(r.id)); return ( @@ -384,7 +383,7 @@ export default function FilesScreen() { {selection.size} selected requestDelete(Array.from(selection))} + onPress={() => requestDelete(selectedRows)} hitSlop={8} style={styles.headerBtn} accessibilityLabel="Delete selected" @@ -406,7 +405,7 @@ export default function FilesScreen() { ) : null} - {current.name} + {headerName} @@ -443,19 +442,19 @@ export default function FilesScreen() { {canBack ? ( - {stack.map((f) => f.name).join(' / ')} + {['Files', ...path].join(' / ')} ) : null} ); }; - const renderRow = (item: FileNode) => { + const renderRow = (item: FileRow) => { const isDir = isDirectory(item); - const Icon = isDir ? Folder : pickFileIcon(item.name); + const Icon = isDir ? Folder : pickFileIcon(item.displayName); const tint = isDir ? (coloredIcons ? c.calendar.blue : c.textMuted) - : (coloredIcons ? fileIconColor(item.name, c) : c.textMuted); + : (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted); const selected = selection.has(item.id); const busy = busyId === item.id; return ( @@ -472,7 +471,7 @@ export default function FilesScreen() { {showIcons && } - {item.name} + {item.displayName} {item.size != null && !isDir ? ( @@ -501,12 +500,12 @@ export default function FilesScreen() { ); }; - const renderGridCell = (item: FileNode) => { + const renderGridCell = (item: FileRow) => { const isDir = isDirectory(item); - const Icon = isDir ? Folder : pickFileIcon(item.name); + const Icon = isDir ? Folder : pickFileIcon(item.displayName); const tint = isDir ? (coloredIcons ? c.calendar.blue : c.textMuted) - : (coloredIcons ? fileIconColor(item.name, c) : c.textMuted); + : (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted); const selected = selection.has(item.id); return ( )} - {item.name} + {item.displayName} {item.size != null && !isDir ? ( @@ -560,7 +559,7 @@ export default function FilesScreen() { - {stack.length > 1 ? 'This folder is empty' : 'No files yet'} + {path.length > 0 ? 'This folder is empty' : 'No files yet'} ); @@ -629,30 +628,30 @@ export default function FilesScreen() { setActionsTarget(null)} - onPreview={(n) => { + onPreview={(r) => { setActionsTarget(null); - void previewFile(n); + void previewFile(r); }} - onDownload={(n) => { + onDownload={(r) => { setActionsTarget(null); - void downloadFile(n); + void downloadFile(r); }} - onRename={(n) => { + onRename={(r) => { setActionsTarget(null); - setRenameTarget(n); - setRenameValue(n.name); + setRenameTarget(r); + setRenameValue(r.displayName); }} - onDelete={(n) => { + onDelete={(r) => { setActionsTarget(null); - requestDelete([n.id]); + requestDelete([r]); }} /> 1 - ? `Delete ${confirmDelete.ids.length} items?` + confirmDelete && confirmDelete.rows.length > 1 + ? `Delete ${confirmDelete.rows.length} items?` : 'Delete this item?' } message="Folders are deleted along with everything inside them. This can't be undone." @@ -722,12 +721,12 @@ function PromptModal(props: PromptModalProps) { } interface ActionsSheetProps { - target: FileNode | null; + target: FileRow | null; onClose: () => void; - onPreview: (n: FileNode) => void; - onDownload: (n: FileNode) => void; - onRename: (n: FileNode) => void; - onDelete: (n: FileNode) => void; + onPreview: (r: FileRow) => void; + onDownload: (r: FileRow) => void; + onRename: (r: FileRow) => void; + onDelete: (r: FileRow) => void; } function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDelete }: ActionsSheetProps) { @@ -742,7 +741,7 @@ function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDele - {target.name} + {target.displayName} {!isDir ? ( onPreview(target)}>