mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: open a files link on the folder and file it names
This commit is contained in:
@@ -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/<folder>` and `?preview=` links opened only the Files tab (file 10
|
||||
// "deep links" on the Files row, 07 "Deep link /files/<folder> 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\);/);
|
||||
});
|
||||
});
|
||||
@@ -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/<folder>/<sub>`); `preview`: a file in it to
|
||||
// open (`?preview=`); `shared`: a node of a drive shared with the user
|
||||
// (`?shared=<owner>:<id>`). 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':
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/** A `/files/<folder path>[?preview=<name>]` 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 (`<owner>:<id>`): 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<PendingFilesLinkState>((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);
|
||||
}
|
||||
@@ -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() {
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
// A `/files/<folder>?preview=<name>` 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 = (
|
||||
|
||||
Reference in New Issue
Block a user