fix: open the folder a mail folder link names instead of only the mail tab

This commit is contained in:
Linus Rath
2026-10-31 04:50:08 +01:00
parent aa25d08dcc
commit ed490b7188
5 changed files with 97 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
import type { Mailbox } from '../api/types';
/** Roles a folder link may name instead of an id (webmail lib/deep-links.ts). */
const ALIASED_ROLES = new Set(['inbox', 'sent', 'drafts', 'trash', 'archive', 'junk']);
/**
* The folder a `mail/folder/<ref>` link names: an exact id first, then a role
* alias (the own account's folder before a shared one's). Null when nothing in
* the loaded folders matches, a link to a folder that was deleted or lives in
* another account (webmail resolveFolderRef).
*/
export function resolveFolderRef(ref: string, mailboxes: readonly Mailbox[]): string | null {
if (mailboxes.some((m) => m.id === ref)) return ref;
if (ALIASED_ROLES.has(ref)) {
const byRole = mailboxes.find((m) => m.role === ref && !m.isShared)
?? mailboxes.find((m) => m.role === ref);
return byRole?.id ?? null;
}
return null;
}
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { handleDeepLink, parseDeepLink } from '../linking';
import { usePendingMailFolder } from '../pending-mail-folder';
import { resolveFolderRef } from '../../lib/folder-ref';
import type { Mailbox } from '../../api/types';
// A `mail/folder/<ref>` link opened only the Mail tab; the folder it named
// was dropped (file 10, "/mail/folder/<ref> deep link opens only the tab").
const box = (id: string, role: string | null, isShared = false) => ({ id, name: id, role, isShared } as unknown as Mailbox);
describe('mail/folder deep link', () => {
it('parks the folder for the mail list', async () => {
const link = parseDeepLink('bulwarkmobile://mail/folder/archive');
const navigation = { isReady: () => true, navigate: vi.fn() };
expect(await handleDeepLink(link!, { navigation: navigation as never, resolveThreadId: async () => null })).toBe(true);
expect(usePendingMailFolder.getState().consume()).toBe('archive');
expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Mail' });
});
it('resolves an id or a role, the own folder first, and nothing for an unknown one', () => {
const boxes = [box('g:in', 'inbox', true), box('in', 'inbox'), box('f1', null)];
expect(resolveFolderRef('f1', boxes)).toBe('f1');
expect(resolveFolderRef('inbox', boxes)).toBe('in');
expect(resolveFolderRef('trash', boxes)).toBeNull();
expect(resolveFolderRef('gone', boxes)).toBeNull();
});
it('is opened by the mail list', () => {
const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
expect(list).toMatch(/const id = ref === null \? null : resolveFolderRef\(ref, mailboxes\);/);
expect(list).toMatch(/if \(id !== currentMailboxId\) void selectMailbox\(id\);/);
});
});
+3
View File
@@ -9,6 +9,7 @@ import type { RootStackParamList } from './types';
import { setPendingSettingsTab } from './pending-settings-tab';
import { setPendingCalendarOpen } from './pending-calendar-open';
import { setPendingMailSearch } from './pending-mail-search';
import { setPendingMailFolder } from './pending-mail-folder';
import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link';
import { insecurePairingLinkError, parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth';
import { MAX_ACCOUNTS } from '../lib/account-utils';
@@ -283,6 +284,8 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr
navigation.navigate('MainTabs', { screen: 'Mail' } as never);
return true;
case 'folder':
// The mail list opens the folder once its folders are loaded.
setPendingMailFolder(link.ref);
navigation.navigate('MainTabs', { screen: 'Mail' } as never);
return true;
case 'calendar':
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand';
// A `mail/folder/<ref>` deep link arrives before the mail list is mounted or
// its folders are loaded. The reference is parked here and consumed by
// EmailListScreen, which opens the folder once it can resolve it.
interface PendingMailFolderState {
ref: string | null;
set: (ref: string | null) => void;
consume: () => string | null;
}
export const usePendingMailFolder = create<PendingMailFolderState>((set, get) => ({
ref: null,
set: (ref) => set({ ref }),
consume: () => {
const ref = get().ref;
if (ref !== null) set({ ref: null });
return ref;
},
}));
export function setPendingMailFolder(ref: string | null): void {
usePendingMailFolder.getState().set(ref);
}
+14
View File
@@ -68,6 +68,8 @@ import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context';
import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
import type { RootStackParamList } from '../navigation/types';
import { usePendingMailSearch } from '../navigation/pending-mail-search';
import { usePendingMailFolder } from '../navigation/pending-mail-folder';
import { resolveFolderRef } from '../lib/folder-ref';
import type { Attachment, Email } from '../api/types';
import { createFrameMonitor, logModuleTimes, perfOnce, perfStart } from '../lib/perf-marks';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
@@ -1202,6 +1204,18 @@ export default function EmailListScreen({
}
}, [ensureMailboxes, mailboxes.length]);
// A `mail/folder/<ref>` link opens its folder once the folders are here;
// one that names no folder of this account leaves the list as it is.
const pendingFolder = usePendingMailFolder((s) => s.ref);
React.useEffect(() => {
if (pendingFolder === null || mailboxes.length === 0) return;
const ref = usePendingMailFolder.getState().consume();
const id = ref === null ? null : resolveFolderRef(ref, mailboxes);
if (!id) return;
clearSearchAndFilters();
if (id !== currentMailboxId) void selectMailbox(id);
}, [pendingFolder, mailboxes, currentMailboxId, selectMailbox, clearSearchAndFilters]);
React.useEffect(() => {
if (mailboxes.length > 0 && !currentMailboxId) {
const own = ownMailboxes(mailboxes);