From d684b00e9b92e808adef89e878a5945cd6c68b65 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:29:46 +0100 Subject: [PATCH] feat: open a files link on the folder and file it names --- src/navigation/__tests__/files-link.test.ts | 48 +++++++++++++++++++++ src/navigation/linking.ts | 29 +++++++++++-- src/navigation/pending-files-link.ts | 37 ++++++++++++++++ src/screens/FilesScreen.tsx | 47 ++++++++++++++++++++ 4 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 src/navigation/__tests__/files-link.test.ts create mode 100644 src/navigation/pending-files-link.ts diff --git a/src/navigation/__tests__/files-link.test.ts b/src/navigation/__tests__/files-link.test.ts new file mode 100644 index 00000000..835dee2c --- /dev/null +++ b/src/navigation/__tests__/files-link.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { handleDeepLink, parseDeepLink } from '../linking'; +import { usePendingFilesLink } from '../pending-files-link'; + +// `/files/` and `?preview=` links opened only the Files tab (file 10 +// "deep links" on the Files row, 07 "Deep link /files/ and ?preview= +// not handled"). Webmail lib/deep-links.ts parseFilesPath. + +const nav = () => ({ isReady: () => true, navigate: vi.fn() }); + +describe('files deep links', () => { + it('parses the folder path, the file to preview and a shared node', () => { + expect(parseDeepLink('bulwarkmobile://files')).toEqual({ kind: 'files' }); + expect(parseDeepLink('bulwarkmobile://files/Docs/Q%203')) + .toEqual({ kind: 'files', path: '/Docs/Q 3' }); + expect(parseDeepLink('https://mail.example.com/de/files/Docs?preview=a%20b.pdf')) + .toEqual({ kind: 'files', path: '/Docs', preview: 'a b.pdf' }); + expect(parseDeepLink('https://mail.example.com/files?preview=top.txt')) + .toEqual({ kind: 'files', path: '/', preview: 'top.txt' }); + expect(parseDeepLink('bulwarkmobile://files?shared=acc-2%3An1&preview=x.txt')) + .toEqual({ kind: 'files', path: '/', shared: 'acc-2:n1', preview: 'x.txt' }); + }); + + it('parks the link for the Files tab', async () => { + const navigation = nav(); + await handleDeepLink(parseDeepLink('bulwarkmobile://files/Docs?preview=a.pdf')!, { + navigation: navigation as never, resolveThreadId: async () => null, + }); + expect(usePendingFilesLink.getState().consume()).toEqual({ path: '/Docs', preview: 'a.pdf' }); + expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Files' }); + + await handleDeepLink(parseDeepLink('bulwarkmobile://files')!, { + navigation: navigation as never, resolveThreadId: async () => null, + }); + expect(usePendingFilesLink.getState().consume()).toBeNull(); + }); + + // No RN renderer in the test env: check the screen's wiring instead. + it('is followed by the Files tab once the drive is listed', () => { + const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/FilesScreen.tsx'), 'utf8'); + expect(screen).toMatch(/if \(!pendingFilesLink \|\| loading \|\| !driveRef\.current\?\.account\) return;\s*const link = usePendingFilesLink\.getState\(\)\.consume\(\);/); + expect(screen).toMatch(/stack = resolveFolderPath\(driveNodes\.filter\(\(n\) => !n\.isShared\), link\.path\);/); + expect(screen).toMatch(/listFolder\(driveNodes, parentId\)\.find\(\(n\) => n\.name === link\.preview && !isFolder\(n\)\)/); + expect(screen).toMatch(/if \(row && !isSymlink\(row\) && canPreviewInApp\(row\)\) openPreview\(row\);/); + }); +}); diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index 28e4fa5d..ddadeac4 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -16,6 +16,7 @@ import { } from './pending-calendar-date'; import { setPendingMailSearch } from './pending-mail-search'; import { setPendingMailFolder } from './pending-mail-folder'; +import { setPendingFilesLink } from './pending-files-link'; import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link'; import { insecurePairingLinkError, parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth'; import { MAX_ACCOUNTS } from '../lib/account-utils'; @@ -52,7 +53,10 @@ export type DeepLink = // `/contacts/new`, with `?email=`/`?addEmail=` and `?name=`/`?addName=`. | { kind: 'contact-new'; email?: string; name?: string } | { kind: 'contacts' } - | { kind: 'files' } + // `path`: a folder (`/files//`); `preview`: a file in it to + // open (`?preview=`); `shared`: a node of a drive shared with the user + // (`?shared=:`). None of them: just the Files tab. + | { kind: 'files'; path?: string; preview?: string; shared?: string } | { kind: 'settings'; tab?: string } | { kind: 'compose'; to: EmailAddress[]; cc: EmailAddress[]; bcc?: EmailAddress[]; subject?: string; body?: string }; @@ -191,8 +195,19 @@ export function parseDeepLink(url: string): DeepLink | null { } return { kind: 'contacts' }; } - case 'files': - return { kind: 'files' }; + case 'files': { + // Webmail parseFilesPath. + const parts = segments.slice(1).map(decodeSegment).filter(Boolean); + const preview = search.get('preview') || undefined; + const shared = search.get('shared') || undefined; + if (parts.length === 0 && !preview && !shared) return { kind: 'files' }; + return { + kind: 'files', + path: `/${parts.join('/')}`, + ...(preview ? { preview } : {}), + ...(shared ? { shared } : {}), + }; + } case 'settings': return { kind: 'settings', tab: kind ? decodeSegment(kind) : undefined }; case 'compose': { @@ -355,6 +370,14 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr navigation.navigate('MainTabs', { screen: 'Contacts' } as never); return true; case 'files': + // The Files tab walks to the folder once it has listed the drive. + if (link.path || link.preview || link.shared) { + setPendingFilesLink({ + path: link.path ?? '/', + ...(link.preview ? { preview: link.preview } : {}), + ...(link.shared ? { shared: link.shared } : {}), + }); + } navigation.navigate('MainTabs', { screen: 'Files' } as never); return true; case 'settings': diff --git a/src/navigation/pending-files-link.ts b/src/navigation/pending-files-link.ts new file mode 100644 index 00000000..2d967283 --- /dev/null +++ b/src/navigation/pending-files-link.ts @@ -0,0 +1,37 @@ +import { create } from 'zustand'; + +/** A `/files/[?preview=]` link (webmail FilesDeepLink). */ +export interface FilesLink { + /** Folder path in the drive, leading-slashed (`/` for the root). */ + path: string; + /** File name to preview once the folder is open. */ + preview?: string; + /** + * A node of a drive shared with the user, by the id the Files tab lists it + * under (`:`): it has no path under the user's own root. + */ + shared?: string; +} + +// A files link arrives before the Files tab is mounted or has listed the +// drive. It is parked here and consumed by FilesScreen once it can walk the +// drive, like pending-mail-folder. +interface PendingFilesLinkState { + link: FilesLink | null; + set: (link: FilesLink | null) => void; + consume: () => FilesLink | null; +} + +export const usePendingFilesLink = create((set, get) => ({ + link: null, + set: (link) => set({ link }), + consume: () => { + const link = get().link; + if (link) set({ link: null }); + return link; + }, +})); + +export function setPendingFilesLink(link: FilesLink | null): void { + usePendingFilesLink.getState().set(link); +} diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index c17caa77..bdff6dd9 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -89,6 +89,7 @@ import { RECENT_FILES_SHOWN, folderPathOf, nodePlace, resolveFolderPath, type FolderStep, } from '../lib/files-places'; import { NO_FILES_PLACES, useFilesPlacesStore } from '../stores/files-places-store'; +import { usePendingFilesLink, type FilesLink } from '../navigation/pending-files-link'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; // A file row carries the display name alongside the rest of the node. @@ -1409,6 +1410,52 @@ export default function FilesScreen() { ) : null; + // A `/files/?preview=` link (webmail files-app): once the + // drive is listed, walk to the folder and open the file there. A link into + // a shared drive names the node by id (`?shared=`). + const applyFilesLink = (link: FilesLink) => { + let stack: FolderStep[] | null = null; + let fileNode: FileNode | undefined; + if (link.shared) { + const place = nodePlace(driveNodes, link.shared); + if (place && isFolder(place.node)) stack = [...place.stack, { id: place.node.id, name: place.node.name }]; + else if (place) { + stack = place.stack; + fileNode = place.node; + } + } else if (link.path === `/${TRASH_FOLDER_NAME}`) { + setPath([]); + setInTrash(true); + setSearchQuery(''); + clearSelection(); + return; + } else { + stack = resolveFolderPath(driveNodes.filter((n) => !n.isShared), link.path); + } + if (!stack) { + toast.error(link.preview + ? t('deep_link.file_not_found', 'This file is no longer available.') + : t('deep_link.folder_not_found', 'This folder is no longer available.')); + return; + } + openFolderStack(stack); + if (!link.preview && !fileNode) return; + const parentId = stack.length > 0 ? stack[stack.length - 1].id : null; + const target = fileNode ?? listFolder(driveNodes, parentId).find((n) => n.name === link.preview && !isFolder(n)); + const row = target ? { ...target, displayName: target.name } : null; + if (row && !isSymlink(row) && canPreviewInApp(row)) openPreview(row); + else toast.error(t('deep_link.file_not_found', 'This file is no longer available.')); + }; + const applyFilesLinkRef = useRef(applyFilesLink); + applyFilesLinkRef.current = applyFilesLink; + const pendingFilesLink = usePendingFilesLink((s) => s.link); + useEffect(() => { + // Wait for the listing: the walk needs the drive's folders. + if (!pendingFilesLink || loading || !driveRef.current?.account) return; + const link = usePendingFilesLink.getState().consume(); + if (link) applyFilesLinkRef.current(link); + }, [pendingFilesLink, loading]); + let body: React.ReactNode; if (loading && !refreshing) { body = (