mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge branch 'perf/fastest'
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
name: "CI: Perf guard"
|
||||
|
||||
# Render passes, component renders and JMAP requests per open / search /
|
||||
# folder switch, against a production build with the dev mock's
|
||||
# benchmark-sized corpus (scripts/perf-guard). Counts, not timings: they do
|
||||
# not drift with runner load, so a failure means the code does more work.
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
perf-guard:
|
||||
name: Perf guard
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
# The same bundler the Docker image uses, with the dev mock compiled in.
|
||||
- name: Build
|
||||
env:
|
||||
DEV_MOCK_JMAP: "true"
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
GIT_COMMIT: ${{ github.sha }}
|
||||
run: npx next build --webpack
|
||||
|
||||
- name: Run perf guard
|
||||
env:
|
||||
CI: "true"
|
||||
run: npm run test:perf-guard
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type LiteOAuthDiscovery,
|
||||
} from "@/lib/auth/lite-oauth";
|
||||
import { toAsciiDomain } from "@/lib/idn";
|
||||
import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view";
|
||||
|
||||
/** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */
|
||||
function completeAddressDomain(username: string): string {
|
||||
@@ -155,6 +156,7 @@ function LoginPageContent() {
|
||||
: "";
|
||||
const mobileState = mobileRedirectUri ? rawMobileState : "";
|
||||
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||
usePrefetchMailView(!isMobileHandoff);
|
||||
const { login, loginWithToken, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowTokenLogin, loginShowRememberMe, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||
|
||||
@@ -949,6 +949,73 @@ const emails: MockEmail[] = [
|
||||
},
|
||||
];
|
||||
|
||||
appendBenchCorpus(Number(process.env.DEV_MOCK_JMAP_BULK) || 0);
|
||||
|
||||
/**
|
||||
* DEV_MOCK_JMAP_BULK=<n> replaces the fixtures above with n synthetic
|
||||
* messages shaped like the benchmark rig's seed (repos/benchmark/seed):
|
||||
* "#<n>" subjects, 60 % plain text, the rest HTML or with an attachment,
|
||||
* spread 70/15/5/5/5 over Inbox, Sent, Drafts, Junk and Trash. For
|
||||
* performance tests, where a folder of a few messages hides every per-row
|
||||
* cost. Deterministic, so counts are stable.
|
||||
*/
|
||||
function appendBenchCorpus(count: number): void {
|
||||
if (count <= 0) return;
|
||||
let seed = 0x1234abcd;
|
||||
const rand = () => {
|
||||
seed = (seed + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
const pick = <T,>(list: T[]): T => list[Math.floor(rand() * list.length)];
|
||||
const prefixes = ['Re:', 'Fwd:', '', '', '', '[BENCH]', '[BENCH]', '[BENCH]'];
|
||||
const nouns = ['Quarterly review', 'Project status', 'Lunch tomorrow?', 'Merge request',
|
||||
'Production incident', 'Vacation policy update', 'Architecture review', 'Customer feedback',
|
||||
'Standup notes', 'Performance budget', 'Release plan', 'Database migration', 'Security audit',
|
||||
'Team offsite', 'Onboarding doc', 'Roadmap Q4', 'Bug triage', 'Design review', 'Hiring loop',
|
||||
'Budget proposal', 'New customer', 'Postmortem draft'];
|
||||
const senders = ['Alice', 'Bob', 'Carol', 'Dave', 'Marketing', 'Updates', 'Reports'];
|
||||
const domains = ['github.com', 'example.com', 'stripe.com', 'vendor.io', 'product.com', 'analytics.io'];
|
||||
const lorem = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. ';
|
||||
const text = (chars: number) => lorem.repeat(Math.ceil(chars / lorem.length)).slice(0, chars);
|
||||
const folders: Array<[string, number]> = [['mb-inbox', 0.70], ['mb-sent', 0.85], ['mb-drafts', 0.90], ['mb-junk', 0.95], ['mb-trash', 1]];
|
||||
const now = Date.now();
|
||||
emails.length = 0;
|
||||
for (let n = 1; n <= count; n++) {
|
||||
const name = pick(senders);
|
||||
const body = `Hi,\n\n${text(800 + Math.floor(rand() * 2000))}\n\nSynthetic seed message ${n}/${count}.`;
|
||||
const kind = rand();
|
||||
// The seed's attachment messages carry their own subject.
|
||||
const subject = kind >= 0.95 ? `[BENCH] Document attached #${n}` : `${pick(prefixes)} ${pick(nouns)} #${n}`.trim();
|
||||
const slot = rand();
|
||||
const mailboxId = folders.find(([, upTo]) => slot < upTo)![0];
|
||||
const id = `email-bench-${n}`;
|
||||
const email: MockEmail = {
|
||||
id, threadId: `thread-bench-${n}`, mailboxIds: { [mailboxId]: true },
|
||||
keywords: { ...(rand() < 0.7 ? { $seen: true } : {}), ...(mailboxId === 'mb-drafts' ? { $draft: true } : {}) },
|
||||
size: body.length + 600, receivedAt: new Date(now - n * 37 * 60_000).toISOString(),
|
||||
from: [{ name, email: `${name.toLowerCase()}@${pick(domains)}` }],
|
||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||
subject, preview: body.slice(4, 200).replace(/\s+/g, ' '),
|
||||
hasAttachment: kind >= 0.95,
|
||||
textBody: [{ partId: 'p1', blobId: `blob-${id}-p1`, size: body.length, type: 'text/plain' }],
|
||||
htmlBody: [],
|
||||
bodyValues: { p1: { value: body } },
|
||||
};
|
||||
if (kind >= 0.60 && kind < 0.95) {
|
||||
const html = `<!doctype html><html><body style="font-family:system-ui"><h2>${subject}</h2><p>${text(200)}</p><table border="1" cellpadding="6"><tr><th>Item</th><th>Value</th></tr>${Array.from({ length: 8 }, (_, i) => `<tr><td>row-${i}</td><td>${rand().toFixed(8)}</td></tr>`).join('')}</table><p>${text(kind < 0.80 ? 8000 : 40000)}</p></body></html>`;
|
||||
email.htmlBody = [{ partId: 'p2', blobId: `blob-${id}-p2`, size: html.length, type: 'text/html' }];
|
||||
email.bodyValues.p2 = { value: html };
|
||||
}
|
||||
if (email.hasAttachment) {
|
||||
email.attachments = [{ partId: 'att1', blobId: `blob-${id}-att1`, size: 50_000, name: `synthetic-${n}.txt`, type: 'text/plain' }];
|
||||
}
|
||||
emails.push(email);
|
||||
}
|
||||
recomputeMailboxCounts();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identities
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2414,6 +2481,24 @@ const METHOD_HANDLERS: Record<string, MethodHandler> = {
|
||||
// Resolve back-references between method calls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A JSON pointer with "*" wildcard steps, as JMAP result references use. */
|
||||
function resolvePointer(value: unknown, path: string): unknown {
|
||||
const steps = path.replace(/^\//, '').split('/').filter((step) => step.length > 0);
|
||||
let current: unknown[] = [value];
|
||||
for (const step of steps) {
|
||||
const next: unknown[] = [];
|
||||
for (const item of current) {
|
||||
if (step === '*') {
|
||||
if (Array.isArray(item)) next.push(...item);
|
||||
} else if (item && typeof item === 'object') {
|
||||
next.push((item as Record<string, unknown>)[step]);
|
||||
}
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return steps.includes('*') ? current : current[0];
|
||||
}
|
||||
|
||||
function resolveBackReferences(
|
||||
methodCalls: Array<[string, MethodArgs, string]>,
|
||||
responses: MethodResult[],
|
||||
@@ -2422,15 +2507,17 @@ function resolveBackReferences(
|
||||
const [method, args, callId] = call;
|
||||
const resolved = { ...args };
|
||||
|
||||
// Handle #ids back-reference (used by Email/get after Email/query)
|
||||
if (resolved['#ids']) {
|
||||
const ref = resolved['#ids'] as { resultOf: string; name: string; path: string };
|
||||
// Every "#name" argument is a back-reference (RFC 8620 §3.7): the value at
|
||||
// `path` in an earlier response replaces it under the plain name, where a
|
||||
// "*" step collects across an array (e.g. /list/*/threadId).
|
||||
for (const key of Object.keys(resolved)) {
|
||||
if (!key.startsWith('#')) continue;
|
||||
const ref = resolved[key] as { resultOf: string; name: string; path: string };
|
||||
const refResponse = responses.find((r) => r[2] === ref.resultOf && r[0] === ref.name);
|
||||
if (refResponse) {
|
||||
const path = ref.path.replace(/^\//, '');
|
||||
resolved.ids = refResponse[1][path] as string[];
|
||||
resolved[key.slice(1)] = resolvePointer(refResponse[1], ref.path);
|
||||
}
|
||||
delete resolved['#ids'];
|
||||
delete resolved[key];
|
||||
}
|
||||
|
||||
return [method, resolved, callId] as [string, MethodArgs, string];
|
||||
|
||||
@@ -219,6 +219,8 @@ describe('message-row-actions', () => {
|
||||
const onSelect = vi.fn();
|
||||
const [thread] = groupEmailsByThread([mail('a')]);
|
||||
render(<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={onSelect} />);
|
||||
// The hover actions mount once the row has been pointed at.
|
||||
fireEvent.pointerEnter(screen.getByTestId('email-list-item'));
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Later' });
|
||||
const star = screen.getByTestId('hover-action-star');
|
||||
@@ -234,12 +236,14 @@ describe('message-row-actions', () => {
|
||||
useSettingsStore.setState({ hoverActions: [] } as never);
|
||||
act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later' }]));
|
||||
render(row(mail('a')));
|
||||
fireEvent.pointerEnter(screen.getByTestId('email-list-item'));
|
||||
expect(screen.getByRole('button', { name: 'Later' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('honours the folder roles the plugin asked for', () => {
|
||||
act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later', mailboxRoles: ['sent'] }]));
|
||||
render(row(mail('a')));
|
||||
fireEvent.pointerEnter(screen.getByTestId('email-list-item'));
|
||||
expect(screen.queryByRole('button', { name: 'Later' })).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -617,3 +617,32 @@ describe('ThreadListItem Mountain View desktop row', () => {
|
||||
expect(onToggleExpand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem prefetch on mouse press', () => {
|
||||
const renderWithPrefetch = (onPrefetch: (email: Email) => void) => {
|
||||
const [thread] = groupEmailsByThread([makeEmail()]);
|
||||
return render(
|
||||
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} onPrefetch={onPrefetch} />,
|
||||
);
|
||||
};
|
||||
|
||||
it('asks for the message when a mouse is pressed on the row', () => {
|
||||
const onPrefetch = vi.fn();
|
||||
renderWithPrefetch(onPrefetch);
|
||||
fireEvent.pointerDown(screen.getByTestId('email-list-item'), { pointerType: 'mouse', button: 0 });
|
||||
expect(onPrefetch).toHaveBeenCalledWith(expect.objectContaining({ id: 'email-1' }));
|
||||
});
|
||||
|
||||
it('leaves touches, other buttons and presses on controls in the row alone', () => {
|
||||
const onPrefetch = vi.fn();
|
||||
renderWithPrefetch(onPrefetch);
|
||||
const row = screen.getByTestId('email-list-item');
|
||||
fireEvent.pointerDown(row, { pointerType: 'touch', button: 0 });
|
||||
fireEvent.pointerDown(row, { pointerType: 'mouse', button: 2 });
|
||||
fireEvent.pointerEnter(row);
|
||||
const control = row.querySelector('button');
|
||||
expect(control).not.toBeNull();
|
||||
fireEvent.pointerDown(control!, { pointerType: 'mouse', button: 0 });
|
||||
expect(onPrefetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
// A row without an attachment mounts no attachment loader: there is nothing
|
||||
// for it to load or show, and the list mounts dozens of rows at a time.
|
||||
|
||||
const ListAttachmentChips = vi.fn(() => null);
|
||||
vi.mock('../attachment-chips', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../attachment-chips')>()),
|
||||
ListAttachmentChips,
|
||||
}));
|
||||
vi.mock('@/hooks/use-email-drag', () => ({ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }) }));
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = { identities: [], username: null };
|
||||
return { useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state) };
|
||||
});
|
||||
|
||||
const { ThreadListItem } = await import('../thread-list-item');
|
||||
const { groupEmailsByThread } = await import('@/lib/thread-utils');
|
||||
|
||||
const row = (id: string, hasAttachment: boolean) => ({
|
||||
id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: { $seen: true }, size: 1,
|
||||
receivedAt: '2026-01-15T10:00:00Z', subject: id, from: [{ name: 'Ann', email: 'ann@example.com' }], hasAttachment,
|
||||
}) as unknown as Email;
|
||||
|
||||
describe('attachment chips in message rows', () => {
|
||||
it('mounts the loader only for rows that may have attachments', () => {
|
||||
for (const [email, expected] of [[row('plain', false), 0], [row('with', true), 1]] as const) {
|
||||
ListAttachmentChips.mockClear();
|
||||
const [thread] = groupEmailsByThread([email]);
|
||||
const { unmount } = render(
|
||||
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} onOpenAttachment={() => {}} />,
|
||||
);
|
||||
expect(ListAttachmentChips.mock.calls.length > 0 ? 1 : 0).toBe(expected);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Email, ThreadGroup } from '@/lib/jmap/types';
|
||||
import { areThreadRowPropsEqual } from '../thread-list-item';
|
||||
|
||||
type RowProps = Parameters<typeof areThreadRowPropsEqual>[0];
|
||||
|
||||
const email = (id: string, keywords: Record<string, boolean> = {}): Email =>
|
||||
({ id, threadId: 't1', subject: `Subject ${id}`, keywords, receivedAt: '2026-09-21T10:00:00Z' }) as unknown as Email;
|
||||
|
||||
const group = (emails: Email[], over: Partial<ThreadGroup> = {}): ThreadGroup => ({
|
||||
threadId: 't1',
|
||||
threadKey: 'acct:t1',
|
||||
emails,
|
||||
latestEmail: emails[0],
|
||||
participantNames: ['Ada'],
|
||||
hasUnread: false,
|
||||
hasStarred: false,
|
||||
hasPinned: false,
|
||||
hasAttachment: false,
|
||||
hasAnswered: false,
|
||||
hasForwarded: false,
|
||||
emailCount: emails.length,
|
||||
...over,
|
||||
});
|
||||
|
||||
const onToggleExpand = () => {};
|
||||
const onEmailSelect = () => {};
|
||||
const props = (over: Partial<RowProps>): RowProps => ({
|
||||
thread: group([email('e1')]),
|
||||
isExpanded: false,
|
||||
onToggleExpand,
|
||||
onEmailSelect,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('areThreadRowPropsEqual', () => {
|
||||
it('treats a regrouped thread holding the very same messages as unchanged', () => {
|
||||
const e1 = email('e1');
|
||||
// groupEmailsByThread builds new ThreadGroup objects on every pass.
|
||||
expect(areThreadRowPropsEqual(props({ thread: group([e1]) }), props({ thread: group([e1]) }))).toBe(true);
|
||||
});
|
||||
|
||||
it('re-renders when a message in the thread was replaced (read, starred, tagged ...)', () => {
|
||||
const before = email('e1');
|
||||
const after = email('e1', { $seen: true });
|
||||
expect(areThreadRowPropsEqual(props({ thread: group([before]) }), props({ thread: group([after]) }))).toBe(false);
|
||||
});
|
||||
|
||||
it('re-renders when the late thread count arrives', () => {
|
||||
const e1 = email('e1');
|
||||
expect(areThreadRowPropsEqual(
|
||||
props({ thread: group([e1], { emailCount: 1 }) }),
|
||||
props({ thread: group([e1], { emailCount: 3 }) }),
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('re-renders when a thread flag or the participants change', () => {
|
||||
const e1 = email('e1');
|
||||
const base = props({ thread: group([e1]) });
|
||||
expect(areThreadRowPropsEqual(base, props({ thread: group([e1], { hasUnread: true }) }))).toBe(false);
|
||||
expect(areThreadRowPropsEqual(base, props({ thread: group([e1], { participantNames: ['Ada', 'Grace'] }) }))).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores the open message moving between two OTHER rows', () => {
|
||||
const thread = group([email('e1')]);
|
||||
expect(areThreadRowPropsEqual(
|
||||
props({ thread, selectedEmailId: 'other-1' }),
|
||||
props({ thread, selectedEmailId: 'other-2' }),
|
||||
)).toBe(true);
|
||||
expect(areThreadRowPropsEqual(props({ thread }), props({ thread, selectedEmailId: 'other-1' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('re-renders the row that gains or loses the open message', () => {
|
||||
const thread = group([email('e1'), email('e2')]);
|
||||
expect(areThreadRowPropsEqual(props({ thread, selectedEmailId: 'other' }), props({ thread, selectedEmailId: 'e2' }))).toBe(false);
|
||||
expect(areThreadRowPropsEqual(props({ thread, selectedEmailId: 'e2' }), props({ thread, selectedEmailId: 'other' }))).toBe(false);
|
||||
expect(areThreadRowPropsEqual(props({ thread, selectedEmailId: 'e1' }), props({ thread, selectedEmailId: 'e2' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('counts messages shown by the expanded thread as drawn by the row', () => {
|
||||
const thread = group([email('e1')]);
|
||||
const expandedEmails = [email('e1'), email('e9')];
|
||||
expect(areThreadRowPropsEqual(
|
||||
props({ thread, expandedEmails, isExpanded: true, selectedEmailId: 'other' }),
|
||||
props({ thread, expandedEmails, isExpanded: true, selectedEmailId: 'e9' }),
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('re-renders on any other prop change, including a handler appearing', () => {
|
||||
const thread = group([email('e1')]);
|
||||
expect(areThreadRowPropsEqual(props({ thread }), props({ thread, isExpanded: true }))).toBe(false);
|
||||
expect(areThreadRowPropsEqual(props({ thread }), props({ thread, isLoading: true }))).toBe(false);
|
||||
expect(areThreadRowPropsEqual(props({ thread }), props({ thread, onDelete: () => {} }))).toBe(false);
|
||||
expect(areThreadRowPropsEqual(props({ thread }), props({ thread, onEmailSelect: () => {} }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { render, act, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// A message row follows the role of the folder it is listed in (sender vs
|
||||
// recipient, junk actions), not the folder list itself: the list is a new
|
||||
// array on every count update, and each one re-rendered every row.
|
||||
|
||||
// Called once per render of a row: the render counter.
|
||||
const useEmailDrag = vi.fn(() => ({ dragHandlers: {}, isDragging: false }));
|
||||
vi.mock('@/hooks/use-email-drag', () => ({ useEmailDrag }));
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = { identities: [], username: null };
|
||||
return { useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state) };
|
||||
});
|
||||
|
||||
const { ThreadListItem } = await import('../thread-list-item');
|
||||
const { useEmailStore } = await import('@/stores/email-store');
|
||||
const { groupEmailsByThread } = await import('@/lib/thread-utils');
|
||||
|
||||
const folder = (id: string, role: string | null, unreadEmails = 0) =>
|
||||
({ id, name: id, role, unreadEmails, totalEmails: 1 }) as unknown as Mailbox;
|
||||
const email = {
|
||||
id: 'e1', threadId: 't1', mailboxIds: { sent: true }, keywords: { $seen: true }, size: 1,
|
||||
receivedAt: '2026-01-15T10:00:00Z', subject: 'Hello',
|
||||
from: [{ name: 'Me', email: 'me@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
} as unknown as Email;
|
||||
|
||||
describe('message row store subscription', () => {
|
||||
it('ignores folder list updates that leave its folder role alone', () => {
|
||||
useEmailStore.setState({ selectedMailbox: 'sent', isUnifiedView: false, unifiedRole: null, mailboxes: [folder('inbox', 'inbox'), folder('sent', 'sent')] });
|
||||
const [thread] = groupEmailsByThread([email]);
|
||||
render(<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} />);
|
||||
// Sent shows the recipient.
|
||||
expect(screen.getByText('Bob')).toBeInTheDocument();
|
||||
const mounted = useEmailDrag.mock.calls.length;
|
||||
|
||||
// A count update: a new folder list, same roles.
|
||||
act(() => { useEmailStore.setState({ mailboxes: [folder('inbox', 'inbox', 3), folder('sent', 'sent')] }); });
|
||||
expect(useEmailDrag.mock.calls.length).toBe(mounted);
|
||||
|
||||
// The folder's role itself changing still shows.
|
||||
act(() => { useEmailStore.setState({ mailboxes: [folder('inbox', 'inbox', 3), folder('sent', null)] }); });
|
||||
expect(useEmailDrag.mock.calls.length).toBeGreaterThan(mounted);
|
||||
expect(screen.getByText('Me')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { Profiler } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { clearAllHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
// Opening a message renders the reading pane twice: the row's header with the
|
||||
// body loading, then the fetched body. Each further pass is a full render of
|
||||
// a very large component on the click's critical path, so the count is pinned
|
||||
// here: resets to values the pane already holds, and readiness flags reset in
|
||||
// a layout effect, used to add passes on every open.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const row = (id: string) => ({
|
||||
id, blobId: `b-${id}`, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
subject: `Subject ${id}`, receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
}) as unknown as Email;
|
||||
const full = (id: string) => ({
|
||||
...row(id),
|
||||
textBody: [{ partId: '1', type: 'text/html' }], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [],
|
||||
bodyValues: { '1': { value: `<p>Body of ${id}</p>` } },
|
||||
}) as unknown as Email;
|
||||
|
||||
beforeEach(() => clearAllHooks());
|
||||
|
||||
describe('reading pane renders per open', () => {
|
||||
it('renders the pane a bounded number of times from click to body', async () => {
|
||||
let commits = 0;
|
||||
const pane = (email: Email, isLoading: boolean) => (
|
||||
<Profiler id="viewer" onRender={() => { commits++; }}>
|
||||
<EmailViewer email={email} isLoading={isLoading} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />
|
||||
</Profiler>
|
||||
);
|
||||
const { rerender } = render(pane(full('a'), false));
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
|
||||
|
||||
commits = 0;
|
||||
// The click: the row with the body loading ...
|
||||
rerender(pane(row('b'), true));
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
|
||||
// ... then the fetched message.
|
||||
rerender(pane(full('b'), false));
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
|
||||
|
||||
// 13 before shared empty values and the derived iframe readiness.
|
||||
expect(commits).toBeLessThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { clearAllHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
// The toolbar moves what doesn't fit into its More menu, which takes reading
|
||||
// every button's width: a forced layout. Opening another message usually
|
||||
// leaves the toolbar as it was, and then the last answer is applied without
|
||||
// reading any width. A resize still measures again.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
|
||||
// Reports a size once observing starts, as browsers do, and on demand.
|
||||
const observers = new Set<{ cb: ResizeObserverCallback; width: number }>();
|
||||
class ReportingResizeObserver {
|
||||
private entry: { cb: ResizeObserverCallback; width: number };
|
||||
constructor(cb: ResizeObserverCallback) { this.entry = { cb, width: 800 }; }
|
||||
observe() {
|
||||
observers.add(this.entry);
|
||||
const { cb } = this.entry;
|
||||
queueMicrotask(() => cb([{ contentRect: { width: this.entry.width } } as ResizeObserverEntry], this as never));
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() { observers.delete(this.entry); }
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', ReportingResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
const full = (id: string) => ({
|
||||
id, blobId: `b-${id}`, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
subject: `Subject ${id}`, receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
textBody: [{ partId: '1', type: 'text/html' }], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [],
|
||||
bodyValues: { '1': { value: `<p>Body of ${id}</p>` } },
|
||||
}) as unknown as Email;
|
||||
|
||||
let widthReads = 0;
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
widthReads = 0;
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => { widthReads++; return 40; });
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 30)); });
|
||||
const pane = (email: Email, isLoading = false) => (
|
||||
<EmailViewer email={email} isLoading={isLoading} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />
|
||||
);
|
||||
|
||||
describe('toolbar overflow measuring', () => {
|
||||
it('measures once, not again for the next message with the same toolbar', async () => {
|
||||
// An open shows the message loading, then loaded.
|
||||
const { rerender } = render(pane(full('a'), true));
|
||||
await settle();
|
||||
rerender(pane(full('a')));
|
||||
await settle();
|
||||
expect(widthReads).toBeGreaterThan(0);
|
||||
|
||||
widthReads = 0;
|
||||
rerender(pane(full('b'), true));
|
||||
await settle();
|
||||
rerender(pane(full('b')));
|
||||
await settle();
|
||||
expect(widthReads).toBe(0);
|
||||
});
|
||||
|
||||
it('measures again when the toolbar is resized', async () => {
|
||||
const { rerender } = render(pane(full('a')));
|
||||
await settle();
|
||||
rerender(pane(full('b')));
|
||||
await settle();
|
||||
|
||||
widthReads = 0;
|
||||
await act(async () => {
|
||||
for (const o of observers) { o.width = 500; o.cb([{ contentRect: { width: 500 } } as ResizeObserverEntry], {} as ResizeObserver); }
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
expect(widthReads).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { useTranslations, useFormatter } from 'next-intl';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { isDocumentRTL } from '@/i18n/direction';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
@@ -389,7 +390,15 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const currentUserEmail = useAuthStore((s) => s.primaryIdentity?.email);
|
||||
const calendarInvitationParsingEnabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, events: storeEvents, setSelectedDate } = useCalendarStore();
|
||||
const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({
|
||||
calendars: state.calendars,
|
||||
supportsCalendar: state.supportsCalendar,
|
||||
importEvents: state.importEvents,
|
||||
rsvpEvent: state.rsvpEvent,
|
||||
updateEvent: state.updateEvent,
|
||||
events: state.events,
|
||||
setSelectedDate: state.setSelectedDate,
|
||||
})));
|
||||
|
||||
const [state, setState] = useState<BannerState>('loading');
|
||||
const [parsedEvent, setParsedEvent] = useState<Partial<CalendarEvent> | null>(null);
|
||||
|
||||
+105
-31
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { ThreadListItem } from "./thread-list-item";
|
||||
import { MemoizedThreadListItem } from "./thread-list-item";
|
||||
import { useStableHandlers } from "@/hooks/use-stable-handlers";
|
||||
import type { Attachment } from "@/lib/jmap/types";
|
||||
import type { LoadListAttachments } from "@/lib/list-attachments";
|
||||
import { listRowShowsChips } from "./attachment-chips";
|
||||
@@ -14,6 +15,7 @@ import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } fr
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, type ListRow } from "@/stores/email-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -40,6 +42,8 @@ interface EmailListProps {
|
||||
emails: Email[];
|
||||
selectedEmailId?: string;
|
||||
onEmailSelect?: (email: Email) => void;
|
||||
/** A mouse was pressed on a row: the click that follows will open it. */
|
||||
onEmailPrefetch?: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
/** Mountain View: search for the mail a sender has sent, from the row menu. */
|
||||
onFindFromSender?: (sender: { name: string; email: string }) => void;
|
||||
@@ -82,6 +86,7 @@ export function EmailList({
|
||||
emails,
|
||||
selectedEmailId,
|
||||
onEmailSelect,
|
||||
onEmailPrefetch,
|
||||
onEmailDoubleClick,
|
||||
onFindFromSender,
|
||||
className,
|
||||
@@ -116,7 +121,7 @@ export function EmailList({
|
||||
const tSpam = useTranslations('email_viewer.spam');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tViewer = useTranslations('email_viewer');
|
||||
const { client } = useAuthStore();
|
||||
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
|
||||
const {
|
||||
selectedEmailKeys,
|
||||
selectAllEmails: _selectAllEmails,
|
||||
@@ -149,7 +154,39 @@ export function EmailList({
|
||||
searchQuery,
|
||||
isUnifiedView,
|
||||
unifiedRole,
|
||||
} = useEmailStore();
|
||||
} = useEmailStore(useShallow((state) => ({
|
||||
selectedEmailKeys: state.selectedEmailKeys,
|
||||
selectAllEmails: state.selectAllEmails,
|
||||
clearSelection: state.clearSelection,
|
||||
batchMarkAsRead: state.batchMarkAsRead,
|
||||
batchSetTag: state.batchSetTag,
|
||||
batchDelete: state.batchDelete,
|
||||
batchMoveToMailbox: state.batchMoveToMailbox,
|
||||
batchArchive: state.batchArchive,
|
||||
batchMarkAsSpam: state.batchMarkAsSpam,
|
||||
batchUndoSpam: state.batchUndoSpam,
|
||||
loadMoreEmails: state.loadMoreEmails,
|
||||
hasMoreEmails: state.hasMoreEmails,
|
||||
isLoadingMore: state.isLoadingMore,
|
||||
mailboxes: state.mailboxes,
|
||||
selectedMailbox: state.selectedMailbox,
|
||||
emptyMailbox: state.emptyMailbox,
|
||||
expandedThreadIds: state.expandedThreadIds,
|
||||
threadEmailsCache: state.threadEmailsCache,
|
||||
isLoadingThread: state.isLoadingThread,
|
||||
toggleThreadExpansion: state.toggleThreadExpansion,
|
||||
fetchThreadEmails: state.fetchThreadEmails,
|
||||
markThreadAsRead: state.markThreadAsRead,
|
||||
collapseAllThreads: state.collapseAllThreads,
|
||||
threadEmailCounts: state.threadEmailCounts,
|
||||
searchFilters: state.searchFilters,
|
||||
setSearchFilters: state.setSearchFilters,
|
||||
clearSearchFilters: state.clearSearchFilters,
|
||||
advancedSearch: state.advancedSearch,
|
||||
searchQuery: state.searchQuery,
|
||||
isUnifiedView: state.isUnifiedView,
|
||||
unifiedRole: state.unifiedRole,
|
||||
})));
|
||||
|
||||
// In aggregate role-views (e.g. "All Junk") the selected mailbox is virtual, so
|
||||
// there is no concrete mailbox to read the role from. Fall back to the unified
|
||||
@@ -558,6 +595,46 @@ export function EmailList({
|
||||
for (const [key, emails] of members) emitThreadExpand(key, emails, false);
|
||||
}, [collapseAllThreads, threadMembers]);
|
||||
|
||||
// Rows are memoised (MemoizedThreadListItem), which only pays off if what
|
||||
// they are handed keeps its identity from one render to the next.
|
||||
const rowHandlers = useStableHandlers({
|
||||
onToggleExpandKey: handleToggleThreadExpansion,
|
||||
onCollapseAllThreads: collapseAllThreadsAndNotify,
|
||||
onEmailSelect: (email: Email) => {
|
||||
// Collapse expanded threads when selecting an email outside the expanded thread
|
||||
const currentExpanded = useEmailStore.getState().expandedThreadIds;
|
||||
if (currentExpanded.size > 0) {
|
||||
// Check if the selected email belongs to any expanded thread via its threadId
|
||||
if (!email.threadId || !currentExpanded.has(threadKeyFor(email))) {
|
||||
collapseAllThreadsAndNotify();
|
||||
}
|
||||
}
|
||||
onEmailSelect?.(email);
|
||||
},
|
||||
onEmailDoubleClick,
|
||||
onPrefetch: onEmailPrefetch,
|
||||
onContextMenu: openContextMenu,
|
||||
onOpenConversation,
|
||||
onToggleStar,
|
||||
onMarkAsRead,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetTag,
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
onOpenAttachment,
|
||||
});
|
||||
// One expand toggle per thread, kept for as long as the list lives.
|
||||
const toggleExpandByThread = useRef(new Map<string, () => void>());
|
||||
const toggleExpandFor = (threadKey: string) => {
|
||||
let toggle = toggleExpandByThread.current.get(threadKey);
|
||||
if (!toggle) {
|
||||
toggle = () => rowHandlers.onToggleExpandKey(threadKey);
|
||||
toggleExpandByThread.current.set(threadKey, toggle);
|
||||
}
|
||||
return toggle;
|
||||
};
|
||||
|
||||
// Range-based load more: trigger when last visible item is near the end.
|
||||
// Debounce to prevent rapid cascade when thread grouping reduces item
|
||||
// count below the viewport size (e.g. 2400 emails → fewer thread groups).
|
||||
@@ -587,9 +664,15 @@ export function EmailList({
|
||||
thread.latestEmail.id === selectedEmailId ||
|
||||
thread.emails.some(e => e.id === selectedEmailId)
|
||||
);
|
||||
if (index >= 0) {
|
||||
virtualizer.scrollToIndex(index, { align: 'auto' });
|
||||
}
|
||||
if (index < 0) return;
|
||||
// A row clicked in the list is already on screen; scrollToIndex would
|
||||
// still measure and schedule a scroll. Judge from the virtualizer's own
|
||||
// numbers, which cost no layout read.
|
||||
const item = virtualizer.measurementsCache[index];
|
||||
const top = virtualizer.scrollOffset;
|
||||
const height = virtualizer.scrollRect?.height;
|
||||
if (item && top !== null && height !== undefined && item.start >= top && item.end <= top + height) return;
|
||||
virtualizer.scrollToIndex(index, { align: 'auto' });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedEmailId]);
|
||||
|
||||
@@ -811,36 +894,27 @@ export function EmailList({
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<ThreadListItem
|
||||
<MemoizedThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={expandedThreadIds.has(thread.threadKey)}
|
||||
selectedEmailId={selectedEmailId}
|
||||
isLoading={isLoadingThread === thread.threadKey}
|
||||
expandedEmails={threadEmailsCache.get(thread.threadKey)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadKey)}
|
||||
onCollapseAllThreads={collapseAllThreadsAndNotify}
|
||||
onEmailSelect={(email) => {
|
||||
// Collapse expanded threads when selecting an email outside the expanded thread
|
||||
const currentExpanded = useEmailStore.getState().expandedThreadIds;
|
||||
if (currentExpanded.size > 0) {
|
||||
// Check if the selected email belongs to any expanded thread via its threadId
|
||||
if (!email.threadId || !currentExpanded.has(threadKeyFor(email))) {
|
||||
collapseAllThreadsAndNotify();
|
||||
}
|
||||
}
|
||||
onEmailSelect?.(email);
|
||||
}}
|
||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={openContextMenu}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||
onDelete={onDelete ? (email) => onDelete(email) : undefined}
|
||||
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||
onSetTag={onSetTag}
|
||||
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
||||
onOpenAttachment={onOpenAttachment}
|
||||
onToggleExpand={toggleExpandFor(thread.threadKey)}
|
||||
onCollapseAllThreads={rowHandlers.onCollapseAllThreads}
|
||||
onEmailSelect={rowHandlers.onEmailSelect}
|
||||
onEmailDoubleClick={rowHandlers.onEmailDoubleClick}
|
||||
onPrefetch={rowHandlers.onPrefetch}
|
||||
onContextMenu={rowHandlers.onContextMenu}
|
||||
onOpenConversation={rowHandlers.onOpenConversation}
|
||||
onToggleStar={rowHandlers.onToggleStar}
|
||||
onMarkAsRead={rowHandlers.onMarkAsRead}
|
||||
onDelete={rowHandlers.onDelete}
|
||||
onArchive={rowHandlers.onArchive}
|
||||
onSetTag={rowHandlers.onSetTag}
|
||||
onMarkAsSpam={rowHandlers.onMarkAsSpam}
|
||||
onUndoSpam={rowHandlers.onUndoSpam}
|
||||
onOpenAttachment={rowHandlers.onOpenAttachment}
|
||||
loadAttachments={loadAttachments}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useCopyLink } from "@/hooks/use-copy-link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
|
||||
import { itemsToHide } from "@/lib/toolbar-overflow";
|
||||
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
|
||||
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
|
||||
import { emailDisplayDate } from "@/lib/email-date";
|
||||
@@ -92,6 +93,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
@@ -981,6 +983,12 @@ function SidebarSection({ icon: Icon, title, children }: { icon: React.Component
|
||||
);
|
||||
}
|
||||
|
||||
// Shared "nothing here" values for the per-message attachment state. Every
|
||||
// open resets that state; a fresh [] each time is a new value, so React
|
||||
// renders the viewer once more even though nothing changed.
|
||||
const NO_POSTAL_ATTACHMENTS: PostalMimeAttachment[] = [];
|
||||
const NO_TNEF_ATTACHMENTS: TnefAttachment[] = [];
|
||||
|
||||
export function EmailViewer({
|
||||
email,
|
||||
blobSource,
|
||||
@@ -1189,8 +1197,8 @@ export function EmailViewer({
|
||||
// `sm:hidden` must not be trusted - the viewport may be desktop-sized
|
||||
// while the pane is phone-sized.
|
||||
const isPaneScoped = useIsPaneScoped();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
const { tabletListVisible } = useUIStore(useShallow((state) => ({ tabletListVisible: state.tabletListVisible })));
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore(useShallow((state) => ({ identities: state.identities, client: state.client, isDemoMode: state.isDemoMode, activeAccountId: state.activeAccountId })));
|
||||
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
|
||||
// The standard mail UI supplies message routing for preview/download,
|
||||
// inline images, export and attachment drag-out. Other viewer hosts keep
|
||||
@@ -1381,6 +1389,17 @@ export function EmailViewer({
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [moreMenuSub, moreMenuOpen, isMobile]);
|
||||
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
||||
// What the overflow measurement last applied. It mostly runs from observer
|
||||
// and animation-frame callbacks, where even an updater that returns the
|
||||
// previous state makes React call this component once more before bailing
|
||||
// out; comparing here means an unchanged result schedules nothing at all.
|
||||
const appliedHiddenPrioritiesRef = useRef<Set<number>>(hiddenPriorities);
|
||||
// The toolbar's overflow answers measured so far, by what the toolbar
|
||||
// looked like (see layoutKey), and the width it last had: an open that
|
||||
// brings back a toolbar already measured at this width needs no measuring.
|
||||
// A loading message and a loaded one can differ, so a few are kept.
|
||||
const measuredToolbarRef = useRef(new Map<string, Set<number>>());
|
||||
const toolbarWidthRef = useRef<number | undefined>(undefined);
|
||||
const currentTagIds = getEmailTagIds(email?.keywords);
|
||||
const sortedTagIds = sortTagIds(currentTagIds);
|
||||
// The header spans the reading pane, so it measures its own width rather than
|
||||
@@ -1394,7 +1413,7 @@ export function EmailViewer({
|
||||
// crypto plugin's own email-banner slot, so the host keeps no S/MIME state.
|
||||
const [pluginRenderedHtml, setPluginRenderedHtml] = useState<string | null>(null);
|
||||
const [pluginRenderedText, setPluginRenderedText] = useState<string | null>(null);
|
||||
const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState<PostalMimeAttachment[]>([]);
|
||||
const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState<PostalMimeAttachment[]>(NO_POSTAL_ATTACHMENTS);
|
||||
// Bumped when a plugin calls `api.ui.rerenderEmail` (e.g. the S/MIME plugin
|
||||
// after the user unlocks a key from the banner) to force the onRenderEmailBody
|
||||
// hook to run again for the open message so the body re-decrypts.
|
||||
@@ -1408,12 +1427,12 @@ export function EmailViewer({
|
||||
// TNEF (winmail.dat) support
|
||||
const [tnefHtml, setTnefHtml] = useState<string | null>(null);
|
||||
const [tnefText, setTnefText] = useState<string | null>(null);
|
||||
const [tnefAttachments, setTnefAttachments] = useState<TnefAttachment[]>([]);
|
||||
const [tnefAttachments, setTnefAttachments] = useState<TnefAttachment[]>(NO_TNEF_ATTACHMENTS);
|
||||
|
||||
// Embedded message/rfc822 unwrapping (Outlook forward-as-attachment)
|
||||
const [embeddedEmailHtml, setEmbeddedEmailHtml] = useState<string | null>(null);
|
||||
const [embeddedEmailText, setEmbeddedEmailText] = useState<string | null>(null);
|
||||
const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState<PostalMimeAttachment[]>([]);
|
||||
const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState<PostalMimeAttachment[]>(NO_POSTAL_ATTACHMENTS);
|
||||
|
||||
// Plugin detail sidebar state. Collapsed/width persist across opens and
|
||||
// sessions so the panel reopens the way the user last left it.
|
||||
@@ -1540,11 +1559,34 @@ export function EmailViewer({
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
const applyHidden = (hidden: Set<number>) => {
|
||||
const applied = appliedHiddenPrioritiesRef.current;
|
||||
if (applied.size === hidden.size && [...hidden].every(p => applied.has(p))) return;
|
||||
appliedHiddenPrioritiesRef.current = hidden;
|
||||
setHiddenPriorities(hidden);
|
||||
};
|
||||
|
||||
// Everything the decision depends on that can be read without a layout.
|
||||
const layoutKey = (items: HTMLElement[]) => [
|
||||
toolbarWidthRef.current, toolbarPosition, showToolbarLabels, isTablet, tabletListVisible,
|
||||
...items.map((item) => `${item.dataset.overflowPriority}:${item.className}:${item.textContent}`),
|
||||
].join('|');
|
||||
|
||||
const calculate = () => {
|
||||
rafId = null;
|
||||
const items = Array.from(el.querySelectorAll<HTMLElement>('[data-overflow-item]'));
|
||||
if (items.length === 0) {
|
||||
setHiddenPriorities(prev => prev.size === 0 ? prev : new Set());
|
||||
applyHidden(new Set());
|
||||
return;
|
||||
}
|
||||
// Opening another message usually leaves the toolbar as it was. Then
|
||||
// the last answer still holds: apply it without reading any width,
|
||||
// which would force a layout on every open.
|
||||
const key = layoutKey(items);
|
||||
const known = toolbarWidthRef.current !== undefined ? measuredToolbarRef.current.get(key) : undefined;
|
||||
if (known) {
|
||||
for (const item of items) item.style.display = known.has(Number(item.dataset.overflowPriority)) ? 'none' : '';
|
||||
applyHidden(known);
|
||||
return;
|
||||
}
|
||||
// Sort descending by priority so highest number (least important) is hidden first
|
||||
@@ -1561,25 +1603,34 @@ export function EmailViewer({
|
||||
leftGroup.style.flexShrink = '0';
|
||||
rightGroup.style.flexShrink = '0';
|
||||
el.style.overflow = 'hidden';
|
||||
// Iteratively hide items until content fits
|
||||
// Measure every width in one pass and decide from the numbers (see
|
||||
// itemsToHide) rather than hiding items one by one and re-reading.
|
||||
const groupIds = new Map<Element, string>([[leftGroup, 'left'], [rightGroup, 'right']]);
|
||||
const groupOf = (item: HTMLElement) => {
|
||||
const parent = item.parentElement;
|
||||
if (!parent) return '';
|
||||
if (!groupIds.has(parent)) groupIds.set(parent, String(groupIds.size));
|
||||
return groupIds.get(parent)!;
|
||||
};
|
||||
const measured = items.map((item) => ({ width: item.offsetWidth, group: groupOf(item) }));
|
||||
const overflow = leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap - (containerWidth + 1);
|
||||
const gapOf = {
|
||||
left: parseFloat(getComputedStyle(leftGroup).gap) || 0,
|
||||
right: parseFloat(getComputedStyle(rightGroup).gap) || 0,
|
||||
};
|
||||
const hidden = new Set<number>();
|
||||
const isOverflowing = () =>
|
||||
leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap > containerWidth + 1;
|
||||
for (const item of items) {
|
||||
if (!isOverflowing()) break;
|
||||
// Skip items already hidden by CSS (e.g., on mobile)
|
||||
if (item.offsetWidth === 0) continue;
|
||||
item.style.display = 'none';
|
||||
hidden.add(Number(item.dataset.overflowPriority));
|
||||
for (const i of itemsToHide(measured, overflow, gapOf)) {
|
||||
items[i].style.display = 'none';
|
||||
hidden.add(Number(items[i].dataset.overflowPriority));
|
||||
}
|
||||
// Restore layout
|
||||
leftGroup.style.flexShrink = '';
|
||||
rightGroup.style.flexShrink = '';
|
||||
el.style.overflow = '';
|
||||
setHiddenPriorities(prev => {
|
||||
if (prev.size === hidden.size && [...hidden].every(p => prev.has(p))) return prev;
|
||||
return hidden;
|
||||
});
|
||||
const answers = measuredToolbarRef.current;
|
||||
if (answers.size >= 8) answers.delete(answers.keys().next().value!);
|
||||
answers.set(key, hidden);
|
||||
applyHidden(hidden);
|
||||
};
|
||||
|
||||
const scheduleCalculate = () => {
|
||||
@@ -1587,8 +1638,22 @@ export function EmailViewer({
|
||||
rafId = requestAnimationFrame(calculate);
|
||||
};
|
||||
|
||||
// Recalculate on container resize
|
||||
const resizeObserver = new ResizeObserver(scheduleCalculate);
|
||||
// Recalculate on container resize. An observer reports once as soon as it
|
||||
// starts observing; that first report is the size the synchronous
|
||||
// calculation below has just measured, so it is not worth a second pass.
|
||||
// Any other size invalidates the last measurement.
|
||||
let sawInitialReport = false;
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const width = entries[entries.length - 1]?.contentRect.width;
|
||||
const known = toolbarWidthRef.current;
|
||||
toolbarWidthRef.current = width;
|
||||
if (!sawInitialReport) {
|
||||
sawInitialReport = true;
|
||||
if (width === known) return;
|
||||
}
|
||||
measuredToolbarRef.current.clear();
|
||||
scheduleCalculate();
|
||||
});
|
||||
resizeObserver.observe(el);
|
||||
|
||||
// Recalculate when children change (conditional items, label visibility)
|
||||
@@ -1742,13 +1807,13 @@ export function EmailViewer({
|
||||
setEmailViewDarkOverride(null);
|
||||
setPluginRenderedHtml(null);
|
||||
setPluginRenderedText(null);
|
||||
setPluginRenderedAttachments([]);
|
||||
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
|
||||
setTnefHtml(null);
|
||||
setTnefText(null);
|
||||
setTnefAttachments([]);
|
||||
setTnefAttachments(NO_TNEF_ATTACHMENTS);
|
||||
setEmbeddedEmailHtml(null);
|
||||
setEmbeddedEmailText(null);
|
||||
setEmbeddedEmailAttachments([]);
|
||||
setEmbeddedEmailAttachments(NO_POSTAL_ATTACHMENTS);
|
||||
}, [email?.id, externalContentPolicy]);
|
||||
|
||||
// Crypto-plugin body takeover (S/MIME, PGP, …). A privileged crypto plugin
|
||||
@@ -1758,6 +1823,14 @@ export function EmailViewer({
|
||||
// via its email-banner slot. Falls through to normal rendering otherwise.
|
||||
useEffect(() => {
|
||||
if (!email) return;
|
||||
// No plugin renders bodies: nothing to ask, and no async round through the
|
||||
// hook whose only outcome would be resetting state already reset.
|
||||
if (renderHooks.onRenderEmailBody.size === 0) {
|
||||
setPluginRenderedHtml(null);
|
||||
setPluginRenderedText(null);
|
||||
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
const dataUrlToBytes = (dataUrl: string): Uint8Array | null => {
|
||||
@@ -1802,7 +1875,7 @@ export function EmailViewer({
|
||||
if (!result || !result.handledBy) {
|
||||
setPluginRenderedHtml(null);
|
||||
setPluginRenderedText(null);
|
||||
setPluginRenderedAttachments([]);
|
||||
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
|
||||
return;
|
||||
}
|
||||
setPluginRenderedHtml(typeof result.html === 'string' && result.html ? result.html : null);
|
||||
@@ -1822,7 +1895,7 @@ export function EmailViewer({
|
||||
debug.error('onRenderEmailBody hook failed:', err);
|
||||
setPluginRenderedHtml(null);
|
||||
setPluginRenderedText(null);
|
||||
setPluginRenderedAttachments([]);
|
||||
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -2795,7 +2868,13 @@ export function EmailViewer({
|
||||
|
||||
// Gates the quick reply on the iframe having loaded the current srcDoc, so
|
||||
// it doesn't flash in below a still-resizing iframe.
|
||||
const [iframeReady, setIframeReady] = useState(false);
|
||||
// The srcDoc whose document has been wired up (handleIframeLoad), or null.
|
||||
// Readiness is derived from it rather than reset per message: a reset in
|
||||
// the srcDoc layout effect rendered this component a second time, during
|
||||
// the commit, on every open.
|
||||
const [iframeReadyFor, setIframeReadyFor] = useState<string | null>(null);
|
||||
const latestSrcDocRef = useRef<string | null>(null);
|
||||
const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc;
|
||||
// The plain-text body, where reader-above-quote mounts between text and quote.
|
||||
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
|
||||
// One object per body: React writes innerHTML again whenever this object
|
||||
@@ -2820,7 +2899,7 @@ export function EmailViewer({
|
||||
const frameSizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
useEffect(() => () => frameSizeObserverRef.current?.disconnect(), []);
|
||||
useLayoutEffect(() => {
|
||||
setIframeReady(false);
|
||||
latestSrcDocRef.current = emailIframeSrcDoc;
|
||||
initializedDocRef.current = null;
|
||||
// Runs during commit, before the browser processes the new srcDoc, so
|
||||
// contentDocument here is still the outgoing document.
|
||||
@@ -2902,8 +2981,21 @@ export function EmailViewer({
|
||||
// Auto-resize iframe to fit content
|
||||
// Measure max(documentElement, body): a height:100% wrapper can leave
|
||||
// documentElement.scrollHeight short while the real content lives in body.
|
||||
const applyHeight = () => {
|
||||
// The document as last measured. Re-measuring is not free: the fit
|
||||
// first clears its own transform and width, which dirties the layout,
|
||||
// so the read after it lays the whole mail out again. Reading the
|
||||
// scroll sizes of an untouched document costs nothing, so the poll and
|
||||
// the observers only pay for the full pass when something changed.
|
||||
const sizes = () => ({
|
||||
width: Math.max(doc.body.scrollWidth, doc.documentElement.scrollWidth),
|
||||
height: Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight),
|
||||
viewport: doc.documentElement.clientWidth,
|
||||
});
|
||||
let measured = { width: -1, height: -1, viewport: -1 };
|
||||
const applyHeight = (force = false) => {
|
||||
if (iframe.contentDocument !== doc) return; // navigated away; stale
|
||||
const now = sizes();
|
||||
if (!force && now.width === measured.width && now.height === measured.height && now.viewport === measured.viewport) return;
|
||||
// Shrink desktop-width mail to fit a phone screen first: it changes
|
||||
// the layout height, and the transform doesn't shrink the measured
|
||||
// (layout) height, so the iframe box has to be scaled by hand.
|
||||
@@ -2913,8 +3005,9 @@ export function EmailViewer({
|
||||
);
|
||||
iframe.style.height = height + 'px';
|
||||
lastBodyHeightRef.current = height;
|
||||
measured = sizes();
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(applyHeight);
|
||||
const resizeObserver = new ResizeObserver(() => applyHeight());
|
||||
resizeObserver.observe(doc.body);
|
||||
// Re-fit when the iframe itself changes width. Height-only changes are
|
||||
// our own applyHeight writing back, so they're ignored - re-entering on
|
||||
@@ -2925,11 +3018,11 @@ export function EmailViewer({
|
||||
if (iframe.contentDocument !== doc) return;
|
||||
if (iframe.clientWidth === lastFrameWidth) return;
|
||||
lastFrameWidth = iframe.clientWidth;
|
||||
applyHeight();
|
||||
applyHeight(true);
|
||||
});
|
||||
frameObserver.observe(iframe);
|
||||
frameSizeObserverRef.current = frameObserver;
|
||||
applyHeight();
|
||||
applyHeight(true);
|
||||
// The ResizeObserver only fires on body's border box; a content overflow
|
||||
// that grows scrollHeight without resizing that box (e.g. a height:100%
|
||||
// wrapper, or images that reflow the layout after onload) is otherwise
|
||||
@@ -2943,7 +3036,7 @@ export function EmailViewer({
|
||||
applyHeight();
|
||||
}, 200);
|
||||
window.setTimeout(() => window.clearInterval(poll), 2400);
|
||||
setIframeReady(true);
|
||||
setIframeReadyFor(latestSrcDocRef.current);
|
||||
|
||||
// Hide images that fail to load (dead/mixed-content/unreachable external
|
||||
// URLs) rather than leaving the browser's broken-image placeholder and
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/components/icons";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete } from "@/stores/email-store";
|
||||
import { runBatchEmailAction } from "@/lib/email-action-toast";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -129,7 +130,25 @@ export function MvListToolbar({
|
||||
batchSetTag,
|
||||
isUnifiedView,
|
||||
unifiedRole,
|
||||
} = useEmailStore();
|
||||
} = useEmailStore(useShallow((state) => ({
|
||||
emails: state.emails,
|
||||
mailboxes: state.mailboxes,
|
||||
selectedMailbox: state.selectedMailbox,
|
||||
selectedEmailKeys: state.selectedEmailKeys,
|
||||
// Read at render by resolveSelectedEmails (Move menu owner account).
|
||||
threadEmailsCache: state.threadEmailsCache,
|
||||
selectAllEmails: state.selectAllEmails,
|
||||
clearSelection: state.clearSelection,
|
||||
batchArchive: state.batchArchive,
|
||||
batchDelete: state.batchDelete,
|
||||
batchMarkAsRead: state.batchMarkAsRead,
|
||||
batchMarkAsSpam: state.batchMarkAsSpam,
|
||||
batchUndoSpam: state.batchUndoSpam,
|
||||
batchMoveToMailbox: state.batchMoveToMailbox,
|
||||
batchSetTag: state.batchSetTag,
|
||||
isUnifiedView: state.isUnifiedView,
|
||||
unifiedRole: state.unifiedRole,
|
||||
})));
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collaps
|
||||
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
|
||||
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailDarkModeCss } from "@/lib/email-dark-mode";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate, formatFileSize, cn, cleanPreview } from "@/lib/utils";
|
||||
@@ -96,7 +97,7 @@ export function useConversationExternalContent() {
|
||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||
const { client } = useAuthStore();
|
||||
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
|
||||
const [allowedIds, setAllowedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const allow = useCallback((emailId: string) => {
|
||||
@@ -513,7 +514,7 @@ export function ConversationEmailBody({
|
||||
);
|
||||
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
||||
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
|
||||
const { client } = useAuthStore();
|
||||
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
|
||||
// reader-above-quote: the body the quote was collapsed in, once it is on screen.
|
||||
const [htmlBodyEl, setHtmlBodyEl] = useState<HTMLElement | null>(null);
|
||||
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "@/
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { emailKeyFor, getEmailTagIds } from "@/lib/thread-utils";
|
||||
@@ -45,7 +46,13 @@ export function ThreadEmailItem({
|
||||
// header pools them, so without this they disappear on the way in.
|
||||
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
|
||||
const sender = email.from?.[0];
|
||||
const { selectedMailbox, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
const { selectedMailbox, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(useShallow((state) => ({
|
||||
selectedMailbox: state.selectedMailbox,
|
||||
selectedEmailKeys: state.selectedEmailKeys,
|
||||
toggleEmailSelection: state.toggleEmailSelection,
|
||||
selectRangeEmails: state.selectRangeEmails,
|
||||
clearSelection: state.clearSelection,
|
||||
})));
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const isChecked = selectedEmailKeys.has(emailKeyFor(email));
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder, Archive, Trash2, MailOpen, ShieldAlert } from "@/components/icons";
|
||||
import type { AppIcon } from "@/components/icons";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
|
||||
import { accountTintKey, generateAvatarColor } from "@/lib/account-utils";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
@@ -42,6 +43,44 @@ import { useTranslations } from "next-intl";
|
||||
* it once the row grows a chip line. Extra-compact rows have no avatar and
|
||||
* anchor it on their single line instead (top padding + half a line).
|
||||
*/
|
||||
/**
|
||||
* A mouse press on the row itself, i.e. one about to become a click that opens
|
||||
* it: a touch may be a scroll starting, and a press on a control inside the
|
||||
* row (checkbox, hover action, link) does something else.
|
||||
*/
|
||||
function isMousePressOnRow(e: React.PointerEvent): boolean {
|
||||
if (e.pointerType !== 'mouse' || e.button !== 0) return false;
|
||||
return !(e.target as Element).closest('button, input, a');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a row has been pointed at yet. The hover actions stay display:none
|
||||
* until the pointer is over the row, so mounting them for every row up front
|
||||
* only added a handful of icon buttons per row to every list render. They
|
||||
* mount on the first pointer-enter and stay from then on, so the hover
|
||||
* behaviour itself is unchanged.
|
||||
*/
|
||||
/**
|
||||
* Role of the folder the list shows (sender vs recipient, junk actions). In
|
||||
* aggregate role views the selected mailbox is virtual, so the unified role
|
||||
* stands in for it.
|
||||
*/
|
||||
function listMailboxRole(state: {
|
||||
mailboxes: { id: string; role?: string | null }[];
|
||||
selectedMailbox: string;
|
||||
isUnifiedView: boolean;
|
||||
unifiedRole: string | null;
|
||||
}): string | undefined {
|
||||
return state.mailboxes.find((mb) => mb.id === state.selectedMailbox)?.role
|
||||
?? (state.isUnifiedView ? (state.unifiedRole ?? undefined) : undefined);
|
||||
}
|
||||
|
||||
function useArmOnPointerEnter(): [boolean, () => void] {
|
||||
const [armed, setArmed] = React.useState(false);
|
||||
const arm = React.useCallback(() => setArmed(true), []);
|
||||
return [armed, arm];
|
||||
}
|
||||
|
||||
function UnreadDot({ besideAvatar }: { besideAvatar: boolean }) {
|
||||
const t = useTranslations('email_viewer');
|
||||
return (
|
||||
@@ -63,7 +102,9 @@ function RowChips({ email, loadAttachments, onOpenAttachment }: {
|
||||
}) {
|
||||
const code = useListVerificationCode(email);
|
||||
const leading = code ? <VerificationCodeChip code={code} /> : undefined;
|
||||
if (onOpenAttachment) {
|
||||
// Most rows have no attachment: no loader for them, nor an empty chip row.
|
||||
const mayHaveAttachments = email.hasAttachment || (email.attachments?.length ?? 0) > 0;
|
||||
if (onOpenAttachment && mayHaveAttachments) {
|
||||
return <ListAttachmentChips email={email} load={loadAttachments} onOpen={onOpenAttachment} leading={leading} className="mt-1.5" />;
|
||||
}
|
||||
return leading ? <div className="mt-1.5 flex">{leading}</div> : null;
|
||||
@@ -240,6 +281,8 @@ interface ThreadListItemProps {
|
||||
onCollapseAllThreads?: () => void;
|
||||
onEmailSelect: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
/** A mouse was pressed on the row: the click that follows will open it. */
|
||||
onPrefetch?: (email: Email) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
@@ -258,6 +301,7 @@ interface SingleEmailItemProps {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onPrefetch?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
rowTint: string | null;
|
||||
@@ -283,7 +327,7 @@ const SWIPE_ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: AppIcon; b
|
||||
};
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam, onOpenAttachment, loadAttachments }, ref) {
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onPrefetch, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam, onOpenAttachment, loadAttachments }, ref) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tBatch = useTranslations('email_list.batch_actions');
|
||||
const tHover = useTranslations('settings.email_behavior.hover_actions');
|
||||
@@ -294,12 +338,20 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const showPaperclip = useListHasAttachment(email, loadAttachments);
|
||||
const { selectedMailbox, mailboxes, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
const { selectedMailbox, currentMailboxRole, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore(useShallow((state) => ({
|
||||
selectedMailbox: state.selectedMailbox,
|
||||
// The role, not the folder list: the list is a new array on every
|
||||
// count update, and subscribing to it re-rendered every row for each.
|
||||
currentMailboxRole: listMailboxRole(state),
|
||||
selectedEmailKeys: state.selectedEmailKeys,
|
||||
toggleEmailSelection: state.toggleEmailSelection,
|
||||
selectRangeEmails: state.selectRangeEmails,
|
||||
clearSelection: state.clearSelection,
|
||||
isUnifiedView: state.isUnifiedView,
|
||||
})));
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always
|
||||
// "me"). In aggregate role-views the selected mailbox is virtual → fall back
|
||||
// to the unified role so junk-contextual UI and avatar hiding work.
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const formatAddress = useOwnDomainAddress();
|
||||
@@ -362,6 +414,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const [hoverActionsArmed, armHoverActions] = useArmOnPointerEnter();
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
@@ -452,6 +505,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{...dragHandlers}
|
||||
{...touchHandlers}
|
||||
data-testid="email-list-item"
|
||||
onPointerEnter={armHoverActions}
|
||||
onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(); }}
|
||||
data-email-id={email.id}
|
||||
data-subject={email.subject || ''}
|
||||
data-unread={isUnread ? 'true' : 'false'}
|
||||
@@ -770,7 +825,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions */}
|
||||
{!email.isScheduled && (
|
||||
{hoverActionsArmed && !email.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={resolvedRowTint ? resolvedRowTint : mvRow ? mvHoverBackground(isChecked, isUnread, selected) : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
@@ -804,6 +859,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onCollapseAllThreads,
|
||||
onEmailSelect,
|
||||
onEmailDoubleClick,
|
||||
onPrefetch,
|
||||
onContextMenu,
|
||||
onOpenConversation,
|
||||
onToggleStar,
|
||||
@@ -849,7 +905,17 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
? formatScheduledSendAt(latestEmail.scheduledSendAt, timeFormat)
|
||||
: null;
|
||||
|
||||
const { selectedMailbox, mailboxes, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
const { selectedMailbox, currentMailboxRole, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore(useShallow((state) => ({
|
||||
selectedMailbox: state.selectedMailbox,
|
||||
// The role, not the folder list: the list is a new array on every
|
||||
// count update, and subscribing to it re-rendered every row for each.
|
||||
currentMailboxRole: listMailboxRole(state),
|
||||
selectedEmailKeys: state.selectedEmailKeys,
|
||||
toggleEmailSelection: state.toggleEmailSelection,
|
||||
selectRangeEmails: state.selectRangeEmails,
|
||||
clearSelection: state.clearSelection,
|
||||
isUnifiedView: state.isUnifiedView,
|
||||
})));
|
||||
const showSourceFolder = isUnifiedView && !!latestEmail.sourceFolder;
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const threadAccount = latestEmail.accountId ? getAccountById(latestEmail.accountId) : undefined;
|
||||
@@ -858,8 +924,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always
|
||||
// "me"). Aggregate role-views use a virtual selected mailbox → fall back to
|
||||
// the unified role so junk-contextual UI and avatar hiding work.
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const displayNames = showRecipient
|
||||
? Array.from(new Set(
|
||||
@@ -875,6 +939,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
threadEmails: thread.emails,
|
||||
});
|
||||
|
||||
const [hoverActionsArmed, armHoverActions] = useArmOnPointerEnter();
|
||||
const { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel, isPressed: isThreadPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
@@ -914,6 +979,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
selected={selectedEmailId === latestEmail.id}
|
||||
onClick={() => onEmailSelect(latestEmail)}
|
||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
|
||||
onPrefetch={onPrefetch ? () => onPrefetch(latestEmail) : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
showPreview={showPreview}
|
||||
rowTint={rowTint}
|
||||
@@ -1009,6 +1075,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
<div
|
||||
{...dragHandlers}
|
||||
{...threadLongPressHandlers}
|
||||
onPointerEnter={armHoverActions}
|
||||
onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(latestEmail); }}
|
||||
aria-current={isSelected ? 'true' : undefined}
|
||||
data-unread={hasUnread ? 'true' : 'false'}
|
||||
data-mv-mobile-row={mvMobileRow ? "" : undefined}
|
||||
@@ -1369,7 +1437,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions for thread header */}
|
||||
{!latestEmail.isScheduled && (
|
||||
{hoverActionsArmed && !latestEmail.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
backgroundClassName={rowTint ? rowTint : mvRow ? mvHoverBackground(isChecked, hasUnread, isSelected) : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
@@ -1413,3 +1481,60 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ThreadListItem.displayName = "ThreadListItem";
|
||||
|
||||
/**
|
||||
* Whether two groupings describe the same thread row. The grouping is rebuilt
|
||||
* whenever the list or the thread counts change, which hands every row a new
|
||||
* `thread` object even when its messages are the very same objects.
|
||||
*/
|
||||
function isSameThread(a: ThreadGroup, b: ThreadGroup): boolean {
|
||||
if (a === b) return true;
|
||||
return a.threadKey === b.threadKey
|
||||
&& a.threadId === b.threadId
|
||||
&& a.emailCount === b.emailCount
|
||||
&& a.latestEmail === b.latestEmail
|
||||
&& a.hasUnread === b.hasUnread
|
||||
&& a.hasStarred === b.hasStarred
|
||||
&& a.hasPinned === b.hasPinned
|
||||
&& a.hasAttachment === b.hasAttachment
|
||||
&& a.hasAnswered === b.hasAnswered
|
||||
&& a.hasForwarded === b.hasForwarded
|
||||
&& a.emails.length === b.emails.length
|
||||
&& a.emails.every((email, i) => email === b.emails[i])
|
||||
&& a.participantNames.length === b.participantNames.length
|
||||
&& a.participantNames.every((name, i) => name === b.participantNames[i]);
|
||||
}
|
||||
|
||||
/** The open message, if this row draws it; otherwise none of the row's business. */
|
||||
function openMessageWithin(props: ThreadListItemProps): string | undefined {
|
||||
const id = props.selectedEmailId;
|
||||
if (!id) return undefined;
|
||||
const drawn = props.thread.emails.some((email) => email.id === id)
|
||||
|| (props.expandedEmails?.some((email) => email.id === id) ?? false);
|
||||
return drawn ? id : undefined;
|
||||
}
|
||||
|
||||
/** Props equality for MemoizedThreadListItem. */
|
||||
export function areThreadRowPropsEqual(prev: ThreadListItemProps, next: ThreadListItemProps): boolean {
|
||||
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]) as Set<keyof ThreadListItemProps>;
|
||||
for (const key of keys) {
|
||||
if (key === 'thread') {
|
||||
if (!isSameThread(prev.thread, next.thread)) return false;
|
||||
} else if (key === 'selectedEmailId') {
|
||||
if (openMessageWithin(prev) !== openMessageWithin(next)) return false;
|
||||
} else if (!Object.is(prev[key], next[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* ThreadListItem for the message list. Opening a message, switching folder or
|
||||
* a late thread count re-rendered every row on screen; this one re-renders
|
||||
* only when something it draws has changed. Handlers are compared by
|
||||
* identity, so the list passes stable ones (see useStableHandlers).
|
||||
*/
|
||||
export const MemoizedThreadListItem = React.memo(ThreadListItem, areThreadRowPropsEqual);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// The mail view renders on every open and every list update. The sidebar
|
||||
// (the whole folder tree) only renders again when one of its props changes.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/contexts/drag-drop-context', () => ({
|
||||
useDragDropContext: () => ({ isDragging: false }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-mailbox-drop', () => ({
|
||||
useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-tag-drop', () => ({
|
||||
useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
|
||||
}));
|
||||
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
|
||||
// Called once per render of the sidebar itself: the render counter.
|
||||
const useRouter = vi.fn(() => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }));
|
||||
vi.mock('@/i18n/navigation', () => ({ useRouter }));
|
||||
|
||||
const { Sidebar } = await import('../sidebar');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true } },
|
||||
{ id: 'sent', name: 'Sent', role: 'sent', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
const onMailboxSelect = () => {};
|
||||
|
||||
describe('Sidebar re-renders', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ hideAccountSwitcher: true } as never);
|
||||
});
|
||||
|
||||
it('skips a parent render that changes none of its props, and follows one that does', () => {
|
||||
let rerenderParent!: () => void;
|
||||
let selectFolder!: (id: string) => void;
|
||||
function Parent() {
|
||||
const [, setTick] = useState(0);
|
||||
const [selected, setSelected] = useState('inbox');
|
||||
rerenderParent = () => setTick((n) => n + 1);
|
||||
selectFolder = setSelected;
|
||||
return (
|
||||
<Sidebar mailboxes={mailboxes} selectedMailbox={selected} onMailboxSelect={onMailboxSelect} />
|
||||
);
|
||||
}
|
||||
render(<Parent />);
|
||||
const rendersAfterMount = useRouter.mock.calls.length;
|
||||
|
||||
act(() => rerenderParent());
|
||||
expect(useRouter.mock.calls.length).toBe(rendersAfterMount);
|
||||
|
||||
act(() => selectFolder('sent'));
|
||||
expect(useRouter.mock.calls.length).toBeGreaterThan(rendersAfterMount);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Menu, ArrowLeft, Plus, Search, X, MoreVertical, Terminal, Bell } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -130,7 +131,7 @@ export function MobileHeader({
|
||||
allInboxesSelected = false,
|
||||
}: MobileHeaderProps) {
|
||||
const t = useTranslations('sidebar');
|
||||
const { toggleSidebar, goBack, sidebarOpen } = useUIStore();
|
||||
const { toggleSidebar, goBack, sidebarOpen } = useUIStore(useShallow((state) => ({ toggleSidebar: state.toggleSidebar, goBack: state.goBack, sidebarOpen: state.sidebarOpen })));
|
||||
const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
|
||||
// Pane-aware: in Pro split mode the viewport is desktop-wide while the
|
||||
// pane is narrow. The Tailwind `lg:hidden` variant alone would never fire
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { AppIcon as AppIconType } from "@/components/icons";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link, usePathname } from "@/i18n/navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
@@ -107,7 +108,7 @@ export function MvTopBar({
|
||||
const toggleSidebarCollapsed = useUIStore((s) => s.toggleSidebarCollapsed);
|
||||
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const { supportsCalendar } = useCalendarStore(useShallow((state) => ({ supportsCalendar: state.supportsCalendar })));
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled("calendarEnabled"));
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled"));
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled("filesEnabled"));
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AccountSwitcher } from "./account-switcher";
|
||||
import { iconForName } from "@/components/icons";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
@@ -208,8 +209,8 @@ export function NavigationRail({
|
||||
const router = useRouter();
|
||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const { mailboxes } = useEmailStore();
|
||||
const { supportsCalendar } = useCalendarStore(useShallow((state) => ({ supportsCalendar: state.supportsCalendar })));
|
||||
const { mailboxes } = useEmailStore(useShallow((state) => ({ mailboxes: state.mailboxes })));
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const supportsFiles = client?.supportsFiles() ?? false;
|
||||
const supportsContacts = client?.supportsContacts() ?? false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, Fragment, ReactNode } from "react";
|
||||
import { memo, useState, useEffect, useMemo, Fragment, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
@@ -57,6 +57,7 @@ import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { UNIFIED_MAILBOX_IDS, CROSS_VIEW_IDS } from '@/lib/jmap/types';
|
||||
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
@@ -746,7 +747,7 @@ function TagItem({
|
||||
|
||||
function DemoBanner() {
|
||||
const t = useTranslations('sidebar');
|
||||
const { isDemoMode, loginDemo } = useAuthStore();
|
||||
const { isDemoMode, loginDemo } = useAuthStore(useShallow((state) => ({ isDemoMode: state.isDemoMode, loginDemo: state.loginDemo })));
|
||||
const { startTour, resetTourCompletion } = useTour();
|
||||
const router = useRouter();
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
@@ -808,7 +809,7 @@ function DemoBanner() {
|
||||
function VacationBanner() {
|
||||
const t = useTranslations('sidebar');
|
||||
const router = useRouter();
|
||||
const { isEnabled, isSupported } = useVacationStore();
|
||||
const { isEnabled, isSupported } = useVacationStore(useShallow((state) => ({ isEnabled: state.isEnabled, isSupported: state.isSupported })));
|
||||
|
||||
if (!isSupported || !isEnabled) return null;
|
||||
|
||||
@@ -828,7 +829,7 @@ function VacationBanner() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
function SidebarImpl({
|
||||
mailboxes = [],
|
||||
selectedMailbox = "",
|
||||
selectedKeyword = null,
|
||||
@@ -866,7 +867,7 @@ export function Sidebar({
|
||||
mvShell = false,
|
||||
}: SidebarProps) {
|
||||
const router = useRouter();
|
||||
const { sidebarCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||
const { sidebarCollapsed, toggleSidebarCollapsed } = useUIStore(useShallow((state) => ({ sidebarCollapsed: state.sidebarCollapsed, toggleSidebarCollapsed: state.toggleSidebarCollapsed })));
|
||||
// Mountain View's collapsed rail is a peek, not a commitment: putting the pointer on
|
||||
// it slides the full list back out over the message list and takes it away
|
||||
// again on leaving, so a folder two clicks deep costs no clicks at all. It
|
||||
@@ -875,7 +876,7 @@ export function Sidebar({
|
||||
const [railHovered, setRailHovered] = useState(false);
|
||||
const hoverExpanded = mvShell && sidebarCollapsed && railHovered;
|
||||
const isCollapsed = sidebarCollapsed && !hoverExpanded;
|
||||
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
|
||||
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(useShallow((state) => ({ primaryIdentity: state.primaryIdentity, activeAccountId: state.activeAccountId })));
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
|
||||
const [showAllTags, setShowAllTags] = useState(false);
|
||||
@@ -951,7 +952,7 @@ export function Sidebar({
|
||||
const showUnified =
|
||||
(multiAccountMode || enableUnifiedMailbox) &&
|
||||
(crossAccountActive || (includeGroupInUnified && hasGroupInboxes) || anyCrossViewEnabled);
|
||||
const { unifiedCounts } = useEmailStore();
|
||||
const { unifiedCounts } = useEmailStore(useShallow((state) => ({ unifiedCounts: state.unifiedCounts })));
|
||||
// While the other logins are still reconnecting after a load, the unified
|
||||
// counts cover only the ones already back: say how many, so a partial total
|
||||
// is not read as the whole. Gone as soon as the restore finishes, whatever
|
||||
@@ -1721,3 +1722,11 @@ export function Sidebar({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mail sidebar, re-rendered only when one of its props changes. The mail
|
||||
* view renders on every open and every list update; without this each of
|
||||
* those rendered the whole folder tree again. Its handlers come from
|
||||
* useStableHandlers in the mail view, so they keep their identity.
|
||||
*/
|
||||
export const Sidebar = memo(SidebarImpl);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
|
||||
// A visitor without a session is sent to the login page. Before anything was
|
||||
// signed in on this page load that is a client-side navigation: a reload
|
||||
// downloaded and started the whole app a second time just to show the form.
|
||||
// After a session ended, the reload stays, since it is what clears the mail.
|
||||
|
||||
const nav = vi.hoisted(() => ({ replace: vi.fn(), push: vi.fn(), back: vi.fn(), prefetch: vi.fn() }));
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => nav,
|
||||
usePathname: () => '/en',
|
||||
Link: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
const hardNav = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/browser-navigation', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/browser-navigation')>()),
|
||||
replaceWindowLocation: hardNav,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({ EmailList: () => null }));
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
beforeEach(() => {
|
||||
nav.replace.mockReset();
|
||||
hardNav.mockReset();
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
useEmailStore.setState({ emails: [], mailboxes: [], selectedEmail: null } as never);
|
||||
// checkAuth finding no account to restore.
|
||||
useAuthStore.setState({ isAuthenticated: false, isLoading: false, client: null, checkAuth: vi.fn().mockResolvedValue(undefined) });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('mail view without a session', () => {
|
||||
it('leaves the viewer and editor code alone while sending the visitor to log in', async () => {
|
||||
const idle = vi.fn(() => 1);
|
||||
vi.stubGlobal('requestIdleCallback', idle);
|
||||
vi.stubGlobal('cancelIdleCallback', vi.fn());
|
||||
render(<MailApp />);
|
||||
await act(async () => {});
|
||||
expect(nav.replace).toHaveBeenCalledWith('/login');
|
||||
expect(idle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the login page in place when nothing was signed in yet', async () => {
|
||||
render(<MailApp />);
|
||||
await act(async () => {});
|
||||
expect(nav.replace).toHaveBeenCalledWith('/login');
|
||||
expect(hardNav).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reloads into the login page once a session was used on this page', async () => {
|
||||
act(() => { useAuthStore.setState({ isAuthenticated: true }); });
|
||||
act(() => { useAuthStore.setState({ isAuthenticated: false }); });
|
||||
render(<MailApp />);
|
||||
await act(async () => {});
|
||||
expect(nav.replace).not.toHaveBeenCalled();
|
||||
expect(hardNav).toHaveBeenCalledWith(expect.stringMatching(/\/login$/));
|
||||
});
|
||||
});
|
||||
@@ -120,3 +120,15 @@ describe('command palette commands on the mail page', () => {
|
||||
expect(peekParkedAppCommand()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('code warm-up once signed in', () => {
|
||||
it('fetches the viewer and editor code when the browser is idle', async () => {
|
||||
const idle = vi.fn(() => 1);
|
||||
vi.stubGlobal('requestIdleCallback', idle);
|
||||
vi.stubGlobal('cancelIdleCallback', vi.fn());
|
||||
const { unmount } = render(<MailApp />);
|
||||
await act(async () => {});
|
||||
expect(idle).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
+255
-54
@@ -28,11 +28,12 @@ import { ThreadConversationView } from "@/components/email/thread-conversation-v
|
||||
import { MobileHeader } from "@/components/layout/mobile-header";
|
||||
import { ThreadGroup, Email, Mailbox, type StateChange, isUnifiedMailboxId, UNIFIED_MAILBOX_IDS, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, CROSS_VIEW_IDS, isCrossViewId } from "@/lib/jmap/types";
|
||||
import { useAccountStore, waitForConnectedAccount } from "@/stores/account-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete } from "@/stores/email-store";
|
||||
import { useEmailStore, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete } from "@/stores/email-store";
|
||||
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { formatRejectedRecipients, type JMAPClient } from "@/lib/jmap/client";
|
||||
@@ -41,7 +42,8 @@ import { runBatchEmailAction } from "@/lib/email-action-toast";
|
||||
import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog";
|
||||
import { ShareNotificationToaster } from "@/components/layout/share-notification-toaster";
|
||||
import { CalendarEventNotificationToaster } from "@/components/layout/calendar-event-notification-toaster";
|
||||
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
|
||||
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin, canEnterLoginInPlace } from '@/stores/auth-store';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
@@ -120,6 +122,9 @@ import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbo
|
||||
import { mailtoComposerDraft } from "./mailto-draft";
|
||||
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import { afterRequestDispatch } from "@/lib/after-request-dispatch";
|
||||
import { useStableHandlers } from "@/hooks/use-stable-handlers";
|
||||
import { prefetchEmailContent, requestEmailContent } from "@/lib/email-prefetch";
|
||||
import { createMailboxWithHooks } from "@/lib/plugin-mail-hooks";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
@@ -253,13 +258,26 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Whether the bootstrap fetch for the default mailbox has landed. Deep links
|
||||
// wait for it so their folder switch isn't overwritten (#733).
|
||||
const [initialMailLoadDone, setInitialMailLoadDone] = useState(false);
|
||||
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||
const { identities } = useIdentityStore();
|
||||
// Counts opens, so a slow fetch can tell it was overtaken (handleEmailSelect).
|
||||
const emailOpenSeqRef = useRef(0);
|
||||
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(useShallow((state) => ({
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
client: state.client,
|
||||
logout: state.logout,
|
||||
checkAuth: state.checkAuth,
|
||||
switchAccount: state.switchAccount,
|
||||
activeAccountId: state.activeAccountId,
|
||||
isLoading: state.isLoading,
|
||||
connectionLost: state.connectionLost,
|
||||
isRateLimited: state.isRateLimited,
|
||||
rateLimitUntil: state.rateLimitUntil,
|
||||
})));
|
||||
const { identities } = useIdentityStore(useShallow((state) => ({ identities: state.identities })));
|
||||
const multiAccountIdentities = useProMultiAccountIdentities();
|
||||
useIdentitySync();
|
||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore();
|
||||
const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore(useShallow((state) => ({ loadTrustedSendersBook: state.loadTrustedSendersBook, trustedSendersLoaded: state.trustedSendersLoaded, loadRecentRecipients: state.loadRecentRecipients })));
|
||||
|
||||
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
|
||||
const value = window.prompt(t('email_viewer.reschedule_prompt'));
|
||||
@@ -342,7 +360,25 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// after a split closes and reopens at a different size.
|
||||
const clampEmailListWidth = (width: number) =>
|
||||
paneWidth !== null ? Math.min(width, Math.max(220, Math.floor(paneWidth * 0.55))) : width;
|
||||
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore();
|
||||
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore(useShallow((state) => ({
|
||||
activeView: state.activeView,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
setSidebarOpen: state.setSidebarOpen,
|
||||
setActiveView: state.setActiveView,
|
||||
tabletListVisible: state.tabletListVisible,
|
||||
setTabletListVisible: state.setTabletListVisible,
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
emailListWidth: state.emailListWidth,
|
||||
emailListHeight: state.emailListHeight,
|
||||
setSidebarWidth: state.setSidebarWidth,
|
||||
setEmailListWidth: state.setEmailListWidth,
|
||||
setEmailListHeight: state.setEmailListHeight,
|
||||
persistColumnWidths: state.persistColumnWidths,
|
||||
sidebarCollapsed: state.sidebarCollapsed,
|
||||
resetSidebarWidth: state.resetSidebarWidth,
|
||||
resetEmailListWidth: state.resetEmailListWidth,
|
||||
resetEmailListHeight: state.resetEmailListHeight,
|
||||
})));
|
||||
const {
|
||||
emails,
|
||||
mailboxes,
|
||||
@@ -374,6 +410,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
isLoading,
|
||||
isLoadingEmail,
|
||||
setLoadingEmail,
|
||||
beginOpenEmail,
|
||||
finishOpenEmail,
|
||||
abandonOpenEmail,
|
||||
setPushConnected,
|
||||
handleStateChange,
|
||||
clearNewEmailNotification,
|
||||
@@ -432,7 +471,99 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
selectAccountMailbox,
|
||||
setViewingAccount,
|
||||
refreshCurrentMailbox,
|
||||
} = useEmailStore();
|
||||
} = useEmailStore(useShallow((state) => ({
|
||||
emails: state.emails,
|
||||
mailboxes: state.mailboxes,
|
||||
selectedEmail: state.selectedEmail,
|
||||
selectedMailbox: state.selectedMailbox,
|
||||
quota: state.quota,
|
||||
isPushConnected: state.isPushConnected,
|
||||
newEmailNotification: state.newEmailNotification,
|
||||
selectEmail: state.selectEmail,
|
||||
selectMailbox: state.selectMailbox,
|
||||
selectedEmailKeys: state.selectedEmailKeys,
|
||||
selectAllEmails: state.selectAllEmails,
|
||||
clearSelection: state.clearSelection,
|
||||
toggleEmailSelection: state.toggleEmailSelection,
|
||||
fetchMailboxes: state.fetchMailboxes,
|
||||
fetchEmails: state.fetchEmails,
|
||||
fetchQuota: state.fetchQuota,
|
||||
sendEmail: state.sendEmail,
|
||||
deleteEmail: state.deleteEmail,
|
||||
markAsRead: state.markAsRead,
|
||||
toggleStar: state.toggleStar,
|
||||
patchEmailKeywords: state.patchEmailKeywords,
|
||||
moveToMailbox: state.moveToMailbox,
|
||||
moveToMailboxCrossAware: state.moveToMailboxCrossAware,
|
||||
moveThreadToMailbox: state.moveThreadToMailbox,
|
||||
searchEmails: state.searchEmails,
|
||||
searchQuery: state.searchQuery,
|
||||
setSearchQuery: state.setSearchQuery,
|
||||
isLoading: state.isLoading,
|
||||
isLoadingEmail: state.isLoadingEmail,
|
||||
setLoadingEmail: state.setLoadingEmail,
|
||||
beginOpenEmail: state.beginOpenEmail,
|
||||
finishOpenEmail: state.finishOpenEmail,
|
||||
abandonOpenEmail: state.abandonOpenEmail,
|
||||
setPushConnected: state.setPushConnected,
|
||||
handleStateChange: state.handleStateChange,
|
||||
clearNewEmailNotification: state.clearNewEmailNotification,
|
||||
markAsSpam: state.markAsSpam,
|
||||
undoSpam: state.undoSpam,
|
||||
searchFilters: state.searchFilters,
|
||||
searchMailboxId: state.searchMailboxId,
|
||||
isAdvancedSearchOpen: state.isAdvancedSearchOpen,
|
||||
setSearchFilters: state.setSearchFilters,
|
||||
setSearchMailboxId: state.setSearchMailboxId,
|
||||
scopeSearchToOpenFolder: state.scopeSearchToOpenFolder,
|
||||
clearSearchFilters: state.clearSearchFilters,
|
||||
toggleAdvancedSearch: state.toggleAdvancedSearch,
|
||||
advancedSearch: state.advancedSearch,
|
||||
selectedKeyword: state.selectedKeyword,
|
||||
selectKeyword: state.selectKeyword,
|
||||
hasMoreEmails: state.hasMoreEmails,
|
||||
fetchTagCounts: state.fetchTagCounts,
|
||||
fetchEmailContent: state.fetchEmailContent,
|
||||
isUnifiedView: state.isUnifiedView,
|
||||
unifiedRole: state.unifiedRole,
|
||||
scheduledEmails: state.scheduledEmails,
|
||||
scheduledTotal: state.scheduledTotal,
|
||||
scheduledTotalByAccount: state.scheduledTotalByAccount,
|
||||
scheduledHasMore: state.scheduledHasMore,
|
||||
isLoadingScheduled: state.isLoadingScheduled,
|
||||
isScheduledView: state.isScheduledView,
|
||||
scheduledAccountScope: state.scheduledAccountScope,
|
||||
setScheduledView: state.setScheduledView,
|
||||
fetchScheduledEmails: state.fetchScheduledEmails,
|
||||
loadMoreScheduledEmails: state.loadMoreScheduledEmails,
|
||||
cancelScheduledEmailForEdit: state.cancelScheduledEmailForEdit,
|
||||
rescheduleScheduledEmail: state.rescheduleScheduledEmail,
|
||||
refreshScheduledMetadata: state.refreshScheduledMetadata,
|
||||
cancelUndoSend: state.cancelUndoSend,
|
||||
clearPendingUndoSend: state.clearPendingUndoSend,
|
||||
pendingUndoSend: state.pendingUndoSend,
|
||||
fetchUnifiedEmails: state.fetchUnifiedEmails,
|
||||
fetchCrossView: state.fetchCrossView,
|
||||
refreshUnifiedCounts: state.refreshUnifiedCounts,
|
||||
refreshCrossCounts: state.refreshCrossCounts,
|
||||
crossUnreadCount: state.crossUnreadCount,
|
||||
exitUnifiedView: state.exitUnifiedView,
|
||||
emptyMailbox: state.emptyMailbox,
|
||||
markMailboxAsRead: state.markMailboxAsRead,
|
||||
createMailbox: state.createMailbox,
|
||||
renameMailbox: state.renameMailbox,
|
||||
deleteMailbox: state.deleteMailbox,
|
||||
batchDelete: state.batchDelete,
|
||||
batchArchive: state.batchArchive,
|
||||
batchMarkAsRead: state.batchMarkAsRead,
|
||||
batchMarkAsSpam: state.batchMarkAsSpam,
|
||||
batchUndoSpam: state.batchUndoSpam,
|
||||
accountMailboxes: state.accountMailboxes,
|
||||
viewingAccountId: state.viewingAccountId,
|
||||
selectAccountMailbox: state.selectAccountMailbox,
|
||||
setViewingAccount: state.setViewingAccount,
|
||||
refreshCurrentMailbox: state.refreshCurrentMailbox,
|
||||
})));
|
||||
|
||||
// Mailboxes of the account currently being browsed. Mirrors the store's
|
||||
// action-mailbox resolution so a lookup by `selectedMailbox` hits the same
|
||||
@@ -449,12 +580,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
// Load recent recipients (from the Sent folder) for compose autocomplete.
|
||||
// Runs once when the Sent mailbox is known; the store guards against reloads.
|
||||
useEffect(() => {
|
||||
// Keyed on the folder's id, not the folder list, which changes with every
|
||||
// count; and started when the browser is idle, since it reads up to 300
|
||||
// messages that only the composer needs.
|
||||
const sentMailboxId = useMemo(() => {
|
||||
const sent = mailboxes.find((m) => m.role === 'sent');
|
||||
if (client && sent) {
|
||||
loadRecentRecipients(client, sent.originalId || sent.id);
|
||||
return sent ? sent.originalId || sent.id : null;
|
||||
}, [mailboxes]);
|
||||
useEffect(() => {
|
||||
if (!client || !sentMailboxId) return;
|
||||
const run = () => { void loadRecentRecipients(client, sentMailboxId); };
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
const handle = window.requestIdleCallback(run, { timeout: 5000 });
|
||||
return () => window.cancelIdleCallback(handle);
|
||||
}
|
||||
}, [client, mailboxes, loadRecentRecipients]);
|
||||
const timer = window.setTimeout(run, 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [client, sentMailboxId, loadRecentRecipients]);
|
||||
|
||||
// Pro shell: populate per-account mailbox cache so the sidebar can render
|
||||
// every connected account Thunderbird-style.
|
||||
@@ -1125,13 +1267,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
// Redirect to login if not authenticated. Without a session so far this is
|
||||
// a client-side navigation: reloading would download and start the whole
|
||||
// app again just to show the form.
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
saveRedirectAfterLogin();
|
||||
redirectToLogin();
|
||||
if (canEnterLoginInPlace()) router.replace('/login');
|
||||
else redirectToLogin();
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, router]);
|
||||
|
||||
// onBeforeCompose, in front of every way the composer opens. `open` runs
|
||||
// once plugins agreed (straight away when none listens); a plugin's
|
||||
@@ -1283,6 +1429,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
const loadData = async (attempt = 1) => {
|
||||
try {
|
||||
// login() is already loading mailboxes, quota, this folder's page and
|
||||
// the tag counts for this client: wait for that instead of asking for
|
||||
// all of it again.
|
||||
const inFlight = attempt === 1 ? pendingInitialData(client) : undefined;
|
||||
if (inFlight) {
|
||||
if (inFlight !== 'done') await inFlight.catch(() => {});
|
||||
if (cancelled) return;
|
||||
if (useEmailStore.getState().mailboxes.length > 0) {
|
||||
void refreshScheduledMetadata(client);
|
||||
setInitialMailLoadDone(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const needsMailboxes = useEmailStore.getState().mailboxes.length === 0;
|
||||
// Quota only feeds the sidebar meter - never gate the list on it.
|
||||
void fetchQuota(client);
|
||||
@@ -1345,8 +1504,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]);
|
||||
|
||||
// Warm the code-split viewer/composer chunks once the browser is idle so the
|
||||
// first message open or compose click doesn't wait on a chunk fetch.
|
||||
// first message open or compose click doesn't wait on a chunk fetch. Only
|
||||
// once signed in: a visitor on the way to the login form would otherwise
|
||||
// download and run the whole editor while that form renders.
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
const warm = () => {
|
||||
void import("@/components/email/email-viewer");
|
||||
void import("@/components/email/email-composer");
|
||||
@@ -1357,7 +1519,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
const id = setTimeout(warm, 1500);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Push notifications: one binding per CONNECTED client, kept for as long as
|
||||
// that client is connected.
|
||||
@@ -3235,9 +3397,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!client) return;
|
||||
setSearchQuery(query);
|
||||
useSearchHistoryStore.getState().addRecentSearch(query);
|
||||
// Nothing the mail view renders from is written before the request has
|
||||
// left (see afterRequestDispatch): searchEmails records the query itself,
|
||||
// and the history entry only feeds the suggestion dropdown.
|
||||
afterRequestDispatch(() => useSearchHistoryStore.getState().addRecentSearch(query));
|
||||
if (!isFilterEmpty(searchFilters)) {
|
||||
// advancedSearch reads the query from the store.
|
||||
setSearchQuery(query);
|
||||
await advancedSearch(client);
|
||||
} else {
|
||||
await searchEmails(client, query);
|
||||
@@ -3707,6 +3873,40 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
};
|
||||
|
||||
// Show loading state while checking auth
|
||||
// The sidebar is memoised; handlers that keep their identity across renders
|
||||
// are what let it skip the mail view's renders (opening a message, a list
|
||||
// update), which change nothing it shows.
|
||||
const sidebarHandlers = useStableHandlers({
|
||||
onOpenStarred: mvShell ? goToStarred : undefined,
|
||||
onMailboxSelect: handleMailboxSelect,
|
||||
onTagSelect: handleTagSelect,
|
||||
onUnreadFilterClick: handleUnreadFilterClick,
|
||||
onMarkFolderRead: handleMarkFolderRead,
|
||||
onMarkFolderTreeRead: handleMarkFolderTreeRead,
|
||||
onMarkAllFoldersRead: handleMarkAllFoldersRead,
|
||||
onEmptyFolder: handleEmptyFolderFromContextMenu,
|
||||
onCreateSubfolder: handleCreateSubfolderFromContextMenu,
|
||||
onCreateFolder: handleCreateFolderFromContextMenu,
|
||||
onRenameFolder: handleRenameFolderFromContextMenu,
|
||||
onDeleteFolder: handleDeleteFolderFromContextMenu,
|
||||
onShareFolder: supportsMailboxSharing ? handleShareFolderFromContextMenu : undefined,
|
||||
onImportEmail: handleImportEmailFromContextMenu,
|
||||
onRefreshMailboxes: handleRefreshMailboxes,
|
||||
onCompose: () => {
|
||||
void withComposeGate('compose', {}, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setActiveView('viewer');
|
||||
}
|
||||
});
|
||||
},
|
||||
onSidebarClose: () => setSidebarOpen(false),
|
||||
onAccountMailboxSelect: handleAccountMailboxSelect,
|
||||
});
|
||||
|
||||
if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
@@ -3741,6 +3941,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
||||
|
||||
// Handle email selection with mobile view switching
|
||||
// A mouse pressed on a row: the click that follows opens the message, so
|
||||
// its content is requested now (see lib/email-prefetch). Resolved the way
|
||||
// handleEmailSelect resolves it, so the open finds this very request.
|
||||
const handleEmailPrefetch = (email: Email) => {
|
||||
if (!client) return;
|
||||
const { client: fetchClient, accountId } = resolveEmailBlobContext(email, client);
|
||||
if (fetchClient) prefetchEmailContent(fetchClient, email.id, accountId);
|
||||
};
|
||||
|
||||
const handleEmailSelect = async (
|
||||
email: { id: string } & Partial<Pick<Email, 'sourceClientAccountId' | 'sourceAccountId'>>,
|
||||
) => {
|
||||
@@ -3754,9 +3963,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
setShowComposer(false);
|
||||
}
|
||||
|
||||
// Find the list-level email for metadata (accountId, scheduled flags, etc.)
|
||||
// but don't select it yet — wait for the full fetch to avoid a toolbar flash
|
||||
// caused by rendering with the stub (no bodyValues) then re-rendering with the full email.
|
||||
// Find the list-level email for metadata (accountId, scheduled flags, etc.).
|
||||
//
|
||||
// JMAP email ids are only unique per account (RFC 8621 §1.3) and Stalwart
|
||||
// hands out overlapping id ranges, so in aggregate views one bare id can name
|
||||
@@ -3771,7 +3978,21 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
&& (email.sourceAccountId === undefined || e.sourceAccountId === email.sourceAccountId))
|
||||
?? (email.sourceClientAccountId ? email : undefined);
|
||||
|
||||
setLoadingEmail(true);
|
||||
// Only the newest open may touch the viewer: a slow fetch for a message
|
||||
// the user has already clicked away from must neither replace the one
|
||||
// they are looking at nor end its loading state.
|
||||
const openSeq = ++emailOpenSeqRef.current;
|
||||
const previousEmail = useEmailStore.getState().selectedEmail;
|
||||
// Paint the header from the row at once (subject, sender and date are in
|
||||
// hand) and let the body follow; the viewer shows a row without body
|
||||
// values as loading. Mountain View shows the message inside its
|
||||
// conversation, which is fetched alongside, so it waits for both.
|
||||
const rowEmail = !mvConversationView && listEmail && 'receivedAt' in listEmail ? listEmail as Email : null;
|
||||
if (rowEmail) {
|
||||
beginOpenEmail(rowEmail);
|
||||
} else {
|
||||
setLoadingEmail(true);
|
||||
}
|
||||
|
||||
// On mobile, switch to viewer
|
||||
if (isMobile) {
|
||||
@@ -3788,6 +4009,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const { client: fetchClient, accountId } = resolveEmailBlobContext(listEmail ?? null, client);
|
||||
if (!fetchClient) {
|
||||
console.warn('[mail-app] No connected client for email source');
|
||||
if (openSeq === emailOpenSeqRef.current) abandonOpenEmail(previousEmail);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3796,7 +4018,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const conversationPrefetch = mvConversationView && listEmail?.id && listEmail.threadId
|
||||
? loadMvConversation(listEmail as Email, client)
|
||||
: null;
|
||||
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
||||
// The request a mouse press on the row already started, if any.
|
||||
const fullEmail = await requestEmailContent(fetchClient, email.id, accountId);
|
||||
if (openSeq !== emailOpenSeqRef.current) return;
|
||||
if (fullEmail) {
|
||||
if (listEmail?.isScheduled) {
|
||||
fullEmail.scheduledSendAt = listEmail.scheduledSendAt;
|
||||
@@ -3825,14 +4049,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), MV_CONVERSATION_WAIT_MS)),
|
||||
]);
|
||||
if (conversation !== undefined) setMvConversation(conversation);
|
||||
if (openSeq !== emailOpenSeqRef.current) return;
|
||||
}
|
||||
selectEmail(fullEmail);
|
||||
// One write: the message, and the end of its loading state.
|
||||
finishOpenEmail(fullEmail);
|
||||
// Mark-as-read logic is now handled by useEffect
|
||||
} else {
|
||||
abandonOpenEmail(previousEmail);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch email content:', error);
|
||||
} finally {
|
||||
setLoadingEmail(false);
|
||||
if (openSeq === emailOpenSeqRef.current) abandonOpenEmail(previousEmail);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4222,40 +4449,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
crossAccountActive={crossAccountActive}
|
||||
showCrossUnread={showCrossUnread}
|
||||
showCrossStarred={showCrossStarred}
|
||||
onOpenStarred={mvShell ? goToStarred : undefined}
|
||||
showCrossAll={showCrossAll}
|
||||
crossUnreadCount={crossUnreadCount}
|
||||
onMailboxSelect={handleMailboxSelect}
|
||||
onTagSelect={handleTagSelect}
|
||||
onUnreadFilterClick={handleUnreadFilterClick}
|
||||
onMarkFolderRead={handleMarkFolderRead}
|
||||
onMarkFolderTreeRead={handleMarkFolderTreeRead}
|
||||
onMarkAllFoldersRead={handleMarkAllFoldersRead}
|
||||
onEmptyFolder={handleEmptyFolderFromContextMenu}
|
||||
onCreateSubfolder={handleCreateSubfolderFromContextMenu}
|
||||
onCreateFolder={handleCreateFolderFromContextMenu}
|
||||
onRenameFolder={handleRenameFolderFromContextMenu}
|
||||
onDeleteFolder={handleDeleteFolderFromContextMenu}
|
||||
onShareFolder={supportsMailboxSharing ? handleShareFolderFromContextMenu : undefined}
|
||||
onImportEmail={handleImportEmailFromContextMenu}
|
||||
onRefreshMailboxes={handleRefreshMailboxes}
|
||||
onCompose={() => {
|
||||
void withComposeGate('compose', {}, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setActiveView('viewer');
|
||||
}
|
||||
});
|
||||
}}
|
||||
onSidebarClose={() => setSidebarOpen(false)}
|
||||
{...sidebarHandlers}
|
||||
mvShell={mvShell}
|
||||
multiAccountMode={isEmbedded}
|
||||
accountMailboxes={accountMailboxes}
|
||||
viewingAccountId={viewingAccountId}
|
||||
onAccountMailboxSelect={handleAccountMailboxSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
@@ -4686,6 +4886,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
}}
|
||||
onEmailSelect={handleEmailSelect}
|
||||
onEmailPrefetch={handleEmailPrefetch}
|
||||
onEmailDoubleClick={isEmbedded ? ((email) => {
|
||||
const store = useProTabStore.getState();
|
||||
const hasSplit = store.tabs.some((tab) => tab.paneId === 'split');
|
||||
|
||||
@@ -202,3 +202,65 @@ describe('SearchBox and password managers', () => {
|
||||
expect(input()).toHaveAttribute('type', 'search');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchBox draft', () => {
|
||||
beforeEach(() => {
|
||||
useSearchHistoryStore.setState({ recentSearches: [] });
|
||||
});
|
||||
|
||||
it('keeps the typed text to itself until it is submitted', () => {
|
||||
const onChange = vi.fn();
|
||||
const onSubmit = vi.fn();
|
||||
render(<Harness onChange={onChange} onSubmit={onSubmit} />);
|
||||
fireEvent.change(input(), { target: { value: 'i' } });
|
||||
fireEvent.change(input(), { target: { value: 'in' } });
|
||||
fireEvent.change(input(), { target: { value: 'invoice' } });
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(input().value).toBe('invoice');
|
||||
|
||||
fireEvent.submit(input().closest('form')!);
|
||||
expect(onSubmit).toHaveBeenCalledWith('invoice');
|
||||
});
|
||||
|
||||
it('hands the typed text over when focus leaves the box', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness onChange={onChange} />);
|
||||
fireEvent.change(input(), { target: { value: 'invoice' } });
|
||||
fireEvent.blur(input());
|
||||
expect(onChange).toHaveBeenCalledWith('invoice');
|
||||
});
|
||||
|
||||
it('leaves the search on Enter in an emptied box, like the clear button', () => {
|
||||
const onClear = vi.fn();
|
||||
const onSubmit = vi.fn();
|
||||
render(<Harness initial="invoice" onClear={onClear} onSubmit={onSubmit} />);
|
||||
fireEvent.change(input(), { target: { value: '' } });
|
||||
fireEvent.submit(input().closest('form')!);
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing on Enter in an empty box when no search is in force', () => {
|
||||
const onClear = vi.fn();
|
||||
render(<Harness onClear={onClear} />);
|
||||
fireEvent.submit(input().closest('form')!);
|
||||
expect(onClear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a query set from outside', () => {
|
||||
function Outside() {
|
||||
const [value, setValue] = React.useState('');
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setValue('from:alice')}>set</button>
|
||||
<SearchBox value={value} onChange={setValue} onSubmit={() => {}} onClear={() => {}} onSelectContact={() => {}} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Outside />);
|
||||
fireEvent.click(screen.getByText('set'));
|
||||
expect(input().value).toBe('from:alice');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,18 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact,
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
// The text being typed belongs to the box until it is submitted, cleared or
|
||||
// the box loses focus. `value` is the search in force; handing it every
|
||||
// keystroke re-rendered the whole mail view per character typed.
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [adopted, setAdopted] = useState(value);
|
||||
if (value !== adopted) {
|
||||
setAdopted(value);
|
||||
setDraft(value);
|
||||
}
|
||||
const commitDraft = () => {
|
||||
if (draft !== value) onChange(draft);
|
||||
};
|
||||
|
||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||
// Subscribed only so the memo below recomputes as the lookup's inputs load.
|
||||
@@ -52,13 +64,13 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact,
|
||||
const removeRecentSearch = useSearchHistoryStore((s) => s.removeRecentSearch);
|
||||
|
||||
const suggestions = useMemo<SearchSuggestion[]>(() => {
|
||||
const query = value.trim();
|
||||
const query = draft.trim();
|
||||
const matches = query ? getAutocomplete(query).filter((r) => !r.group && r.email) : [];
|
||||
return buildSearchSuggestions(value, recentSearches, matches);
|
||||
return buildSearchSuggestions(draft, recentSearches, matches);
|
||||
// The three store slices are inputs of getAutocomplete (read via get()),
|
||||
// not of this closure — list them so the memo recomputes when they load.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value, recentSearches, getAutocomplete, contacts, recentRecipients, directoryPrincipals]);
|
||||
}, [draft, recentSearches, getAutocomplete, contacts, recentRecipients, directoryPrincipals]);
|
||||
|
||||
const showDropdown = open && !disabled && suggestions.length > 0;
|
||||
const activeIndex = showDropdown && selectedIndex < suggestions.length ? selectedIndex : -1;
|
||||
@@ -72,6 +84,7 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact,
|
||||
(suggestion: SearchSuggestion) => {
|
||||
close();
|
||||
if (suggestion.kind === "recent") {
|
||||
setDraft(suggestion.query);
|
||||
onChange(suggestion.query);
|
||||
onSubmit(suggestion.query);
|
||||
} else {
|
||||
@@ -112,9 +125,13 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact,
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (value.trim()) {
|
||||
if (draft.trim()) {
|
||||
close();
|
||||
onSubmit(value);
|
||||
onSubmit(draft);
|
||||
} else if (value) {
|
||||
// Enter on an emptied box leaves the search, as the clear button does.
|
||||
close();
|
||||
onClear();
|
||||
}
|
||||
}}
|
||||
className="relative"
|
||||
@@ -127,9 +144,9 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact,
|
||||
type="search"
|
||||
autoFocus={autoFocus}
|
||||
placeholder={placeholder ?? t("search_placeholder_hint")}
|
||||
value={value}
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setDraft(e.target.value);
|
||||
setOpen(true);
|
||||
setSelectedIndex(-1);
|
||||
}}
|
||||
@@ -139,10 +156,13 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact,
|
||||
// Focus moving into the dropdown (e.g. its action buttons) keeps it open.
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (next && containerRef.current?.contains(next)) return;
|
||||
// Anything that reads the query from outside (the filter panel's
|
||||
// search) sees what was typed once focus moves to it.
|
||||
commitDraft();
|
||||
close();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn("ps-9 h-9 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none", value && "pe-8")}
|
||||
className={cn("ps-9 h-9 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none", draft && "pe-8")}
|
||||
role="combobox"
|
||||
aria-expanded={showDropdown}
|
||||
aria-controls={listId}
|
||||
@@ -154,10 +174,11 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact,
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
/>
|
||||
{value && (
|
||||
{draft && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDraft("");
|
||||
close();
|
||||
onClear();
|
||||
}}
|
||||
@@ -171,7 +192,7 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact,
|
||||
{showDropdown && (
|
||||
<SearchSuggestions
|
||||
id={listId}
|
||||
query={value}
|
||||
query={draft}
|
||||
suggestions={suggestions}
|
||||
selectedIndex={activeIndex}
|
||||
onSelect={select}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
|
||||
import { useRouter, usePathname } from "@/i18n/navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -37,10 +38,10 @@ export function useTour() {
|
||||
export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isDemoMode } = useAuthStore();
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const { isDemoMode } = useAuthStore(useShallow((state) => ({ isDemoMode: state.isDemoMode })));
|
||||
const { supportsCalendar } = useCalendarStore(useShallow((state) => ({ supportsCalendar: state.supportsCalendar })));
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
const { supportsWebDAV } = useWebDAVStore();
|
||||
const { supportsWebDAV } = useWebDAVStore(useShallow((state) => ({ supportsWebDAV: state.supportsWebDAV })));
|
||||
const tourCompleted = useSettingsStore((s) => s.tourCompleted);
|
||||
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DragDropProvider, useDragDropContext } from '../drag-drop-context';
|
||||
|
||||
// The provider sits in the mail view and re-renders with it on every open.
|
||||
// Message rows and folders read the context, so a value that changes
|
||||
// identity on each of those renders re-rendered all of them.
|
||||
|
||||
describe('DragDropProvider', () => {
|
||||
it('keeps the context value while no drag starts or ends', () => {
|
||||
let consumerRenders = 0;
|
||||
const Consumer = memo(function Consumer() {
|
||||
useDragDropContext();
|
||||
consumerRenders++;
|
||||
return null;
|
||||
});
|
||||
let rerenderParent!: () => void;
|
||||
function Parent() {
|
||||
const [, setTick] = useState(0);
|
||||
rerenderParent = () => setTick((t) => t + 1);
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<Consumer />
|
||||
</DragDropProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Parent />);
|
||||
const mounted = consumerRenders;
|
||||
act(() => rerenderParent());
|
||||
act(() => rerenderParent());
|
||||
expect(consumerRenders).toBe(mounted);
|
||||
});
|
||||
|
||||
it('still tells consumers when a drag starts', () => {
|
||||
let seen: boolean[] = [];
|
||||
let start!: () => void;
|
||||
function Consumer() {
|
||||
const ctx = useDragDropContext();
|
||||
seen.push(ctx.isDragging);
|
||||
start = () => ctx.startDrag([], 'inbox');
|
||||
return null;
|
||||
}
|
||||
render(<DragDropProvider><Consumer /></DragDropProvider>);
|
||||
seen = [];
|
||||
act(() => start());
|
||||
expect(seen).toContain(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useRef, ReactNode } from "react";
|
||||
import { createContext, useContext, useState, useCallback, useMemo, useRef, ReactNode } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { dragDropHooks } from "@/lib/plugin-hooks";
|
||||
|
||||
@@ -55,8 +55,15 @@ export function DragDropProvider({ children }: { children: ReactNode }) {
|
||||
if (current.current) current.current.dropped = true;
|
||||
}, []);
|
||||
|
||||
// The provider re-renders with the mail view; a new value object each time
|
||||
// would re-render every message row and folder that reads the context.
|
||||
const value = useMemo(
|
||||
() => ({ ...state, startDrag, endDrag, markDropped }),
|
||||
[state, startDrag, endDrag, markDropped],
|
||||
);
|
||||
|
||||
return (
|
||||
<DragDropContext.Provider value={{ ...state, startDrag, endDrag, markDropped }}>
|
||||
<DragDropContext.Provider value={value}>
|
||||
{children}
|
||||
</DragDropContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { DragEvent } from 'react';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
// Every message row runs useEmailDrag. It must not re-render rows when the
|
||||
// list changes, and a drag must still carry the selection as it is when the
|
||||
// drag starts.
|
||||
|
||||
const startDrag = vi.fn();
|
||||
vi.mock('@/contexts/drag-drop-context', () => ({
|
||||
useDragDropContext: () => ({ startDrag, endDrag: vi.fn(), isDragging: false, draggedEmails: [] }),
|
||||
}));
|
||||
|
||||
const { useEmailDrag } = await import('../use-email-drag');
|
||||
const { useEmailStore } = await import('@/stores/email-store');
|
||||
const { emailKeyFor } = await import('@/lib/thread-utils');
|
||||
|
||||
const mail = (id: string) => ({
|
||||
id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: {}, subject: id,
|
||||
receivedAt: '2026-10-01T00:00:00Z', size: 1, hasAttachment: false,
|
||||
}) as unknown as Email;
|
||||
|
||||
describe('useEmailDrag', () => {
|
||||
it('does not re-render its row when the list changes, and drags the selection current at drag start', () => {
|
||||
const a = mail('a');
|
||||
const b = mail('b');
|
||||
useEmailStore.setState({ emails: [a, b], selectedEmailKeys: new Set() });
|
||||
let renders = 0;
|
||||
let handlers!: ReturnType<typeof useEmailDrag>['dragHandlers'];
|
||||
function Row() {
|
||||
renders++;
|
||||
handlers = useEmailDrag({ email: a, sourceMailboxId: 'inbox' }).dragHandlers;
|
||||
return null;
|
||||
}
|
||||
render(<Row />);
|
||||
const mounted = renders;
|
||||
|
||||
act(() => { useEmailStore.setState({ emails: [a, b, mail('c')] }); });
|
||||
act(() => { useEmailStore.setState({ selectedEmailKeys: new Set([emailKeyFor(a), emailKeyFor(b)]) }); });
|
||||
expect(renders).toBe(mounted);
|
||||
|
||||
const dataTransfer = { setData: vi.fn(), setDragImage: vi.fn(), effectAllowed: '' };
|
||||
act(() => { handlers.onDragStart?.({ dataTransfer } as unknown as DragEvent<HTMLDivElement>); });
|
||||
expect(startDrag).toHaveBeenCalledWith([a, b], 'inbox');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// The login form prefetches the mail view so signing in doesn't wait for its
|
||||
// scripts. A mobile hand-off never opens the mail view and skips it.
|
||||
|
||||
const prefetch = vi.fn();
|
||||
const router = { prefetch };
|
||||
vi.mock('@/i18n/navigation', () => ({ useRouter: () => router }));
|
||||
|
||||
const { usePrefetchMailView } = await import('../use-prefetch-mail-view');
|
||||
|
||||
describe('usePrefetchMailView', () => {
|
||||
beforeEach(() => prefetch.mockReset());
|
||||
|
||||
it('prefetches the mail view once', () => {
|
||||
const { rerender } = renderHook(() => usePrefetchMailView(true));
|
||||
rerender();
|
||||
expect(prefetch).toHaveBeenCalledTimes(1);
|
||||
expect(prefetch).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('leaves it alone when the login hands off elsewhere', () => {
|
||||
renderHook(() => usePrefetchMailView(false));
|
||||
expect(prefetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useStableHandlers } from '../use-stable-handlers';
|
||||
|
||||
describe('useStableHandlers', () => {
|
||||
it('keeps each handler identity across renders while calling the latest closure', () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
const { result, rerender } = renderHook(({ onPick }) => useStableHandlers({ onPick }), {
|
||||
initialProps: { onPick: first as (id: string) => void },
|
||||
});
|
||||
|
||||
const stable = result.current;
|
||||
rerender({ onPick: second });
|
||||
|
||||
expect(result.current).toBe(stable);
|
||||
expect(result.current.onPick).toBe(stable.onPick);
|
||||
|
||||
// A row that skipped the re-render still holds `stable.onPick`.
|
||||
stable.onPick('e1');
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(second).toHaveBeenCalledWith('e1');
|
||||
});
|
||||
|
||||
it('passes arguments through and hands back the return value', () => {
|
||||
const { result } = renderHook(() => useStableHandlers({ add: (a: number, b: number) => a + b }));
|
||||
expect(result.current.add(2, 3)).toBe(5);
|
||||
});
|
||||
|
||||
it('leaves an absent handler absent, and follows it when it appears or goes away', () => {
|
||||
const onDelete = vi.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
({ onDelete: handler }: { onDelete?: () => void }) => useStableHandlers({ onOpen: () => {}, onDelete: handler }),
|
||||
{ initialProps: {} as { onDelete?: () => void } },
|
||||
);
|
||||
|
||||
// Rows show a delete button only when there is something to call.
|
||||
expect(result.current.onDelete).toBeUndefined();
|
||||
const withoutDelete = result.current;
|
||||
|
||||
rerender({ onDelete });
|
||||
expect(result.current).not.toBe(withoutDelete);
|
||||
expect(typeof result.current.onDelete).toBe('function');
|
||||
// Handlers that were there all along keep working through the old object too.
|
||||
expect(() => withoutDelete.onOpen()).not.toThrow();
|
||||
result.current.onDelete?.();
|
||||
expect(onDelete).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({});
|
||||
expect(result.current.onDelete).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,20 @@ import { useCallback, useEffect, useRef, DragEvent } from "react";
|
||||
// Chromium ships the `DownloadURL` DataTransfer entry, which the OS reads on
|
||||
// drop to materialize a real file. Firefox and Safari ignore it, so we only
|
||||
// enable drag-out where it actually works.
|
||||
//
|
||||
// Every message row asks, on every render; the answer is kept per user agent.
|
||||
let dragOutSupport: { ua: string; supported: boolean } | null = null;
|
||||
|
||||
export function isDragOutSupported(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const agent = navigator.userAgent || "";
|
||||
if (dragOutSupport?.ua === agent) return dragOutSupport.supported;
|
||||
const supported = detectDragOutSupport();
|
||||
dragOutSupport = { ua: agent, supported };
|
||||
return supported;
|
||||
}
|
||||
|
||||
function detectDragOutSupport(): boolean {
|
||||
const uaData = (navigator as { userAgentData?: { brands?: { brand: string }[] } }).userAgentData;
|
||||
if (uaData?.brands?.length) {
|
||||
return uaData.brands.some((b) => /Chromium|Google Chrome|Microsoft Edge|Brave|Opera/i.test(b.brand));
|
||||
|
||||
@@ -142,7 +142,10 @@ function getReadyBundle(emails: Email[]): { url: string; name: string } | null {
|
||||
}
|
||||
|
||||
export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn {
|
||||
const { selectedEmailKeys, emails } = useEmailStore();
|
||||
// The selection and the list are read when a drag starts, not subscribed
|
||||
// to: every message row runs this hook, and a subscription to the list
|
||||
// re-rendered every row on each list update (new results, a snippet, a
|
||||
// read flag elsewhere), however memoised the rows were.
|
||||
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const client = useAuthStore((state) => state.client);
|
||||
@@ -195,6 +198,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
}, [dragOutEnabled, client, email, filenameOptions]);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
const { selectedEmailKeys, emails } = useEmailStore.getState();
|
||||
if (!dragOutEnabled || !client) return;
|
||||
const isSelected = selectedEmailKeys.has(emailKeyFor(email));
|
||||
const isMulti = isSelected && selectedEmailKeys.size > 1;
|
||||
@@ -207,9 +211,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
} else {
|
||||
prefetchSingle();
|
||||
}
|
||||
}, [dragOutEnabled, client, selectedEmailKeys, email, emails, prefetchSingle, filenameOptions, bundleOptions]);
|
||||
}, [dragOutEnabled, client, email, prefetchSingle, filenameOptions, bundleOptions]);
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
const { selectedEmailKeys, emails } = useEmailStore.getState();
|
||||
// Determine which emails to drag:
|
||||
// - If current email is selected, drag all selected
|
||||
// - If threadEmails provided (thread header), drag all thread emails
|
||||
@@ -285,7 +290,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
});
|
||||
|
||||
startDrag(emailsToDrag, sourceMailboxId);
|
||||
}, [email, selectedEmailKeys, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions, bundleOptions]);
|
||||
}, [email, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions, bundleOptions]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
endDrag();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useState, DragEvent } from "react";
|
||||
import { Mailbox, Email } from "@/lib/jmap/types";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { emailKeyFor } from "@/lib/thread-utils";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
@@ -66,8 +67,15 @@ interface UseMailboxDropReturn {
|
||||
|
||||
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const { client } = useAuthStore();
|
||||
const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailKeys, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
|
||||
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
|
||||
const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailKeys, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore(useShallow((state) => ({
|
||||
moveEmailsToMailbox: state.moveEmailsToMailbox,
|
||||
crossAccountMoveEmails: state.crossAccountMoveEmails,
|
||||
selectedEmailKeys: state.selectedEmailKeys,
|
||||
clearSelection: state.clearSelection,
|
||||
refreshCurrentMailbox: state.refreshCurrentMailbox,
|
||||
mailboxes: state.mailboxes,
|
||||
})));
|
||||
const { isDragging, sourceMailboxId, draggedEmails, endDrag, markDropped } = useDragDropContext();
|
||||
|
||||
// Determine if this is a valid drop target
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { usePaneSize } from "@/hooks/use-pane-size";
|
||||
|
||||
// Tailwind v4 breakpoints
|
||||
@@ -68,7 +69,7 @@ function classifyPane(paneWidth: number | null) {
|
||||
* it directly (mobile navigation helpers etc.).
|
||||
*/
|
||||
export function useDeviceDetection() {
|
||||
const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore();
|
||||
const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore(useShallow((state) => ({ setDeviceType: state.setDeviceType, isMobile: state.isMobile, isTablet: state.isTablet, isDesktop: state.isDesktop })));
|
||||
const paneWidth = usePaneSize();
|
||||
const paneClassification = classifyPane(paneWidth);
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
|
||||
/**
|
||||
* Starts loading the mail view's code while the login form is on screen.
|
||||
* Signing in pushes to it, and without this its scripts were only requested
|
||||
* once the sign-in had finished, adding their download to every login.
|
||||
*/
|
||||
export function usePrefetchMailView(enabled: boolean): void {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
router.prefetch("/");
|
||||
}, [enabled, router]);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useLayoutEffect, useMemo, useRef } from "react";
|
||||
|
||||
type Handler = (...args: never[]) => unknown;
|
||||
type Handlers = Record<string, Handler | undefined>;
|
||||
|
||||
/**
|
||||
* Identity-stable stand-ins for a set of event handlers.
|
||||
*
|
||||
* A list that hands each row inline closures gives every row new props on
|
||||
* every render, so memoising the rows achieves nothing. The handlers returned
|
||||
* here keep their identity for the life of the component and forward to
|
||||
* whatever was passed on the latest render, so a memoised row can be skipped
|
||||
* without ever calling a stale closure.
|
||||
*
|
||||
* Only for handlers invoked after render (clicks, drags, keyboard): the latest
|
||||
* set is published in a layout effect, so calling one during render would
|
||||
* still see the previous render's closure. A handler that is `undefined` stays
|
||||
* `undefined`, because rows use a handler's presence to decide what to show;
|
||||
* the returned object only changes identity when that presence changes.
|
||||
*/
|
||||
export function useStableHandlers<T extends Handlers>(handlers: T): T {
|
||||
const latest = useRef(handlers);
|
||||
useLayoutEffect(() => {
|
||||
latest.current = handlers;
|
||||
});
|
||||
|
||||
const present = Object.keys(handlers)
|
||||
.filter((key) => handlers[key] !== undefined)
|
||||
.sort()
|
||||
.join(",");
|
||||
|
||||
return useMemo(() => {
|
||||
const stable: Handlers = {};
|
||||
for (const key of present ? present.split(",") : []) {
|
||||
stable[key] = ((...args: never[]) => latest.current[key]?.(...args)) as Handler;
|
||||
}
|
||||
return stable as T;
|
||||
}, [present]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useState, DragEvent } from "react";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { dragDropHooks } from "@/lib/plugin-hooks";
|
||||
@@ -26,8 +27,8 @@ interface UseTagDropReturn {
|
||||
|
||||
export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): UseTagDropReturn {
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const { client } = useAuthStore();
|
||||
const { fetchEmails, fetchTagCounts, selectedMailbox } = useEmailStore();
|
||||
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
|
||||
const { fetchEmails, fetchTagCounts, selectedMailbox } = useEmailStore(useShallow((state) => ({ fetchEmails: state.fetchEmails, fetchTagCounts: state.fetchTagCounts, selectedMailbox: state.selectedMailbox })));
|
||||
const { isDragging, endDrag, markDropped } = useDragDropContext();
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
|
||||
@@ -90,6 +90,28 @@ describe('dev-jmap mock server', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api - back-references', () => {
|
||||
it('resolves a wildcard path across a list (RFC 8620 §3.7)', async () => {
|
||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||
body: JSON.stringify({
|
||||
methodCalls: [
|
||||
['Email/query', { accountId: 'dev-account-001', filter: { inMailbox: 'mb-inbox' }, limit: 3 }, '0'],
|
||||
['Email/get', { accountId: 'dev-account-001', '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['threadId'] }, '1'],
|
||||
['Thread/get', { accountId: 'dev-account-001', '#ids': { resultOf: '1', name: 'Email/get', path: '/list/*/threadId' } }, '2'],
|
||||
],
|
||||
}),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
||||
const data = await res.json();
|
||||
const threadIds = data.methodResponses[1][1].list.map((e: { threadId: string }) => e.threadId);
|
||||
expect(threadIds.length).toBeGreaterThan(0);
|
||||
expect(data.methodResponses[2][0]).toBe('Thread/get');
|
||||
expect(data.methodResponses[2][1].list.map((t: { id: string }) => t.id).sort()).toEqual([...new Set<string>(threadIds)].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api - Email/query', () => {
|
||||
it('should filter by mailbox', async () => {
|
||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { forgetEmailPrefetches, prefetchEmailContent, requestEmailContent } from '../email-prefetch';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
// A mouse press on a row starts the body request; the click that follows
|
||||
// takes that request over instead of sending a second one.
|
||||
|
||||
const client = (getEmail = vi.fn(async (id: string) => ({ id }) as Email)) =>
|
||||
({ getAccountId: () => 'login', getEmail }) as unknown as IJMAPClient & { getEmail: typeof getEmail };
|
||||
|
||||
describe('email prefetch', () => {
|
||||
afterEach(() => {
|
||||
forgetEmailPrefetches();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('hands the open the request the press started', async () => {
|
||||
const c = client();
|
||||
prefetchEmailContent(c, 'e1', 'acct');
|
||||
prefetchEmailContent(c, 'e1', 'acct'); // a second press does not ask again
|
||||
const email = await requestEmailContent(c, 'e1', 'acct');
|
||||
|
||||
expect(email?.id).toBe('e1');
|
||||
expect(c.getEmail).toHaveBeenCalledTimes(1);
|
||||
expect(c.getEmail).toHaveBeenCalledWith('e1', 'acct');
|
||||
});
|
||||
|
||||
it('asks again when the press was for another account or message', async () => {
|
||||
const c = client();
|
||||
prefetchEmailContent(c, 'e1', 'acct');
|
||||
await requestEmailContent(c, 'e1', 'other');
|
||||
await requestEmailContent(c, 'e2', 'acct');
|
||||
expect(c.getEmail).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does not hand out a request older than a few seconds', async () => {
|
||||
vi.useFakeTimers();
|
||||
const c = client();
|
||||
prefetchEmailContent(c, 'e1');
|
||||
vi.advanceTimersByTime(6_000);
|
||||
await requestEmailContent(c, 'e1');
|
||||
expect(c.getEmail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('lets the open see a failed prefetch, and an unclaimed one fail quietly', async () => {
|
||||
const failing = client(vi.fn(async () => { throw new Error('offline'); }));
|
||||
prefetchEmailContent(failing, 'e1');
|
||||
await expect(requestEmailContent(failing, 'e1')).rejects.toThrow('offline');
|
||||
prefetchEmailContent(failing, 'e2'); // never claimed: no unhandled rejection
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { formatDate } from '../utils';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
// The list formats every row's date on every render. formatDate keeps its
|
||||
// Intl.DateTimeFormat instances instead of building one per call (which each
|
||||
// toLocale*String call does), without changing a single output.
|
||||
|
||||
describe('formatDate formatters', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useSettingsStore.setState({ dateFormat: 'smart', timeFormat: '24h', dateLocale: 'auto' });
|
||||
});
|
||||
|
||||
it('builds a formatter once per locale and option set, not once per row', () => {
|
||||
useSettingsStore.setState({ dateFormat: 'smart', timeFormat: '12h', dateLocale: 'auto', timeZone: 'Europe/Berlin' });
|
||||
const old = new Date(Date.now() - 40 * 86_400_000);
|
||||
formatDate(old); // warm
|
||||
const Real = Intl.DateTimeFormat;
|
||||
let built = 0;
|
||||
vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(((...args: ConstructorParameters<typeof Intl.DateTimeFormat>) => {
|
||||
built++;
|
||||
return new Real(...args);
|
||||
}) as never);
|
||||
vi.spyOn(Date.prototype, 'toLocaleDateString').mockImplementation(function (this: Date, ...args) { built++; return Real.prototype.format.call(new Real(...args), this); });
|
||||
vi.spyOn(Date.prototype, 'toLocaleTimeString').mockImplementation(function (this: Date, ...args) { built++; return Real.prototype.format.call(new Real(...args), this); });
|
||||
vi.spyOn(Date.prototype, 'toLocaleString').mockImplementation(function (this: Date, ...args) { built++; return Real.prototype.format.call(new Real(...args), this); });
|
||||
|
||||
for (let i = 0; i < 50; i++) formatDate(new Date(old.getTime() - i * 60_000));
|
||||
|
||||
expect(built).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['smart', '24h', 'auto'],
|
||||
['smart', '12h', 'en-GB'],
|
||||
['smart', '24h', 'iso'],
|
||||
['full', '12h', 'auto'],
|
||||
['full', '24h', 'en-US'],
|
||||
['relative', '24h', 'auto'],
|
||||
] as const)('formats as the toLocale* calls did (%s, %s, %s)', (dateFormat, timeFormat, dateLocale) => {
|
||||
useSettingsStore.setState({ dateFormat, timeFormat, dateLocale, timeZone: 'America/New_York' });
|
||||
const now = Date.now();
|
||||
const zone = 'America/New_York';
|
||||
const hour12 = timeFormat === '12h';
|
||||
const numeric = dateLocale === 'auto' ? 'en-US' : dateLocale === 'iso' ? 'en-CA' : dateLocale;
|
||||
for (const ago of [5 * 60_000, 3 * 86_400_000, 40 * 86_400_000, 400 * 86_400_000]) {
|
||||
const d = new Date(now - ago);
|
||||
const got = formatDate(d);
|
||||
if (dateFormat === 'full') {
|
||||
expect(got).toBe(d.toLocaleString(numeric, { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12 }));
|
||||
} else if (dateFormat === 'smart' && ago > 7 * 86_400_000) {
|
||||
expect(got).toBe(d.toLocaleDateString(numeric, { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }));
|
||||
} else if (dateFormat === 'smart' && ago > 86_400_000) {
|
||||
const weekday = d.toLocaleDateString('en-US', { timeZone: zone, weekday: 'short' });
|
||||
expect(got).toBe(`${weekday} ${d.toLocaleTimeString('en-US', { timeZone: zone, hour: '2-digit', minute: '2-digit', hour12 })}`);
|
||||
} else {
|
||||
expect(got.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -200,6 +200,57 @@ describe('JMAP client extensions', () => {
|
||||
expect(result.emails).toHaveLength(1);
|
||||
expect(result.emails[0].searchSnippet).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('deferred', () => {
|
||||
const hits = [
|
||||
{ id: 'e1', threadId: 't1', receivedAt: '2026-08-29T00:00:00Z', subject: 'Invoice', mailboxIds: {}, keywords: {} },
|
||||
{ id: 'e2', threadId: 't2', receivedAt: '2026-08-28T00:00:00Z', subject: 'Other', mailboxIds: {}, keywords: {} },
|
||||
];
|
||||
const searchOrSnippets = (snippets: unknown) => (body: RequestBody) => body.methodCalls[0][0] === 'SearchSnippet/get'
|
||||
? { methodResponses: [snippets] }
|
||||
: { methodResponses: [['Email/query', { ids: ['e1', 'e2'], total: 2 }, '0'], ['Email/get', { list: hits.map((hit) => ({ ...hit })) }, '1']] };
|
||||
|
||||
it('lists the results without waiting for the snippets', async () => {
|
||||
await setup();
|
||||
answer(searchOrSnippets(['SearchSnippet/get', { list: [{ emailId: 'e1', subject: '<mark>Invoice</mark>', preview: null }] }, 'snippets']));
|
||||
|
||||
const result = await client.searchEmails('invoice', 'inbox', undefined, 50, 0, { deferSnippets: true });
|
||||
|
||||
// The search itself no longer carries the snippet call ...
|
||||
expect(requests[0].methodCalls.map((call) => call[0])).toEqual(['Email/query', 'Email/get']);
|
||||
expect(result.emails.map((email) => email.id)).toEqual(['e1', 'e2']);
|
||||
expect(result.emails[0].searchSnippet).toBeUndefined();
|
||||
// ... it follows on its own, for exactly the listed messages.
|
||||
expect(await result.snippets).toEqual([{ emailId: 'e1', subject: '<mark>Invoice</mark>', preview: null }]);
|
||||
expect(requests[1].methodCalls).toEqual([['SearchSnippet/get', {
|
||||
accountId: 'acct-1',
|
||||
filter: { operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'invoice' }] },
|
||||
emailIds: ['e1', 'e2'],
|
||||
}, 'snippets']]);
|
||||
});
|
||||
|
||||
it('asks for no snippets when nothing could be highlighted', async () => {
|
||||
await setup();
|
||||
answer(searchOrSnippets(['SearchSnippet/get', { list: [] }, 'snippets']));
|
||||
|
||||
const structural = await client.advancedSearchEmails({ hasKeyword: '$flagged' }, undefined, 50, 0, { deferSnippets: true });
|
||||
expect(structural.snippets).toBeUndefined();
|
||||
|
||||
answer(() => ({ methodResponses: [['Email/query', { ids: [], total: 0 }, '0'], ['Email/get', { list: [] }, '1']] }));
|
||||
const empty = await client.searchEmails('nothing', undefined, undefined, 50, 0, { deferSnippets: true });
|
||||
expect(empty.snippets).toBeUndefined();
|
||||
expect(requests.every((body) => body.methodCalls[0][0] !== 'SearchSnippet/get')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves to nothing instead of rejecting when the snippet request fails', async () => {
|
||||
await setup();
|
||||
answer(searchOrSnippets(['error', { type: 'requestTooLarge' }, 'snippets']));
|
||||
|
||||
const result = await client.searchEmails('invoice', undefined, undefined, 50, 0, { deferSnippets: true });
|
||||
expect(result.emails).toHaveLength(2);
|
||||
await expect(result.snippets).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMailbox', () => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
|
||||
// A folder page brings its conversation sizes along: Thread/get rides on the
|
||||
// list request through a wildcard back-reference (/list/*/threadId), so the
|
||||
// list does not need a second round trip (and a second render) for them.
|
||||
|
||||
type MethodCall = [string, Record<string, unknown>, string];
|
||||
|
||||
const session = {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } } },
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
|
||||
apiUrl: 'https://mail.example.com/jmap/api',
|
||||
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
||||
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
|
||||
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
|
||||
};
|
||||
|
||||
const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
const page = [
|
||||
{ id: 'e1', threadId: 't1', keywords: {}, receivedAt: '2026-01-02T00:00:00Z', mailboxIds: {}, size: 1, hasAttachment: false },
|
||||
{ id: 'e2', threadId: 't2', keywords: {}, receivedAt: '2026-01-01T00:00:00Z', mailboxIds: {}, size: 1, hasAttachment: false },
|
||||
];
|
||||
|
||||
describe('JMAPClient.getEmails thread counts', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
const sent: MethodCall[][] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
sent.length = 0;
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
async function client(threadAnswer: unknown) {
|
||||
fetchSpy.mockResolvedValueOnce(json(session));
|
||||
const c = JMAPClient.withBearer('https://mail.example.com', 'token', 'user@test.com');
|
||||
await c.connect();
|
||||
fetchSpy.mockReset();
|
||||
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
|
||||
const calls = JSON.parse(init.body as string).methodCalls as MethodCall[];
|
||||
sent.push(calls);
|
||||
if (calls[0][2] === 'asc') {
|
||||
return json({ methodResponses: [['Email/query', { ids: [] }, 'asc'], ['Email/query', { ids: [] }, 'desc'], ['Email/get', { list: [] }, 'get']] });
|
||||
}
|
||||
return json({ methodResponses: [['Email/query', { ids: ['e1', 'e2'], total: 2 }, '0'], ['Email/get', { state: 's1', list: page }, '1'], threadAnswer] });
|
||||
}) as never);
|
||||
return c;
|
||||
}
|
||||
|
||||
it('asks for the threads of the page in the same request and returns their sizes', async () => {
|
||||
const c = await client(['Thread/get', { list: [{ id: 't1', emailIds: ['e1', 'x', 'y'] }, { id: 't2', emailIds: ['e2'] }] }, '2']);
|
||||
|
||||
const result = await c.getEmails('inbox', undefined, 50, 0);
|
||||
|
||||
const list = sent.find((calls) => calls[0][2] === '0')!;
|
||||
expect(list[2]).toEqual(['Thread/get', {
|
||||
accountId: 'acct-1',
|
||||
'#ids': { resultOf: '1', name: 'Email/get', path: '/list/*/threadId' },
|
||||
}, '2']);
|
||||
expect(result.threadCounts).toEqual({ t1: 3, t2: 1 });
|
||||
});
|
||||
|
||||
it('returns no counts when the server could not answer the chained call', async () => {
|
||||
const c = await client(['error', { type: 'invalidResultReference' }, '2']);
|
||||
const result = await c.getEmails('inbox', undefined, 50, 0);
|
||||
expect(result.emails.map((e) => e.id)).toEqual(['e1', 'e2']);
|
||||
expect(result.threadCounts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -172,4 +172,26 @@ describe('JMAPClient truncated body refetch (#884)', () => {
|
||||
expect(emails.find((e) => e.id === 'email-1')?.bodyValues?.html1.value).toBe('full-1');
|
||||
expect(emails.find((e) => e.id === 'email-2')?.bodyValues?.html1.value).toBe('full-2');
|
||||
});
|
||||
|
||||
it('getEmail fetches a body just over the list cap in one request', async () => {
|
||||
// Opening one message needs all of its body; at the list-sized 256 kB cap
|
||||
// a 300 kB newsletter came back truncated and was fetched twice.
|
||||
const client = await createConnectedClient();
|
||||
const html = '<html>' + 'x'.repeat(300_000) + '</html>';
|
||||
fetchSpy.mockImplementation(async (_input: unknown, init?: RequestInit) => {
|
||||
const call = JSON.parse((init as RequestInit).body as string).methodCalls[0][1];
|
||||
const cap = call.maxBodyValueBytes as number;
|
||||
return mockFetchResponse(200, {
|
||||
methodResponses: [['Email/get', { list: [{
|
||||
...truncatedEmail(),
|
||||
bodyValues: { html1: { value: html.slice(0, cap), isTruncated: html.length > cap } },
|
||||
}] }, '0']],
|
||||
});
|
||||
});
|
||||
|
||||
const email = await client.getEmail('email-1');
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(email?.bodyValues?.html1.value).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
hasMarkedSegment,
|
||||
parseSearchSnippet,
|
||||
snippetFilterFor,
|
||||
withSearchSnippets,
|
||||
} from '../search-snippet';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
@@ -96,3 +97,36 @@ describe('attachSearchSnippets', () => {
|
||||
expect(emails[0].searchSnippet).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withSearchSnippets', () => {
|
||||
const email = (id: string, accountId?: string) => ({ id, subject: `Subject ${id}`, accountId }) as unknown as Email;
|
||||
const marked = (emailId: string) => ({ emailId, subject: `<mark>Subject</mark> ${emailId}`, preview: null });
|
||||
|
||||
it('returns new objects only for the emails that gained a snippet', () => {
|
||||
const emails = [email('e1'), email('e2')];
|
||||
const next = withSearchSnippets(emails, [{ list: [marked('e2')] }]);
|
||||
|
||||
expect(next).not.toBe(emails);
|
||||
expect(next[0]).toBe(emails[0]);
|
||||
expect(next[1]).not.toBe(emails[1]);
|
||||
expect(next[1].searchSnippet).toEqual({ subject: '<mark>Subject</mark> e2', preview: null });
|
||||
// The list already on screen is left untouched.
|
||||
expect(emails[1].searchSnippet).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the same list when nothing is highlighted', () => {
|
||||
const emails = [email('e1')];
|
||||
expect(withSearchSnippets(emails, [])).toBe(emails);
|
||||
expect(withSearchSnippets(emails, [{ list: [] }])).toBe(emails);
|
||||
expect(withSearchSnippets(emails, [{ list: [{ emailId: 'e1', subject: 'Subject e1', preview: null }] }])).toBe(emails);
|
||||
expect(withSearchSnippets(emails, [{ list: [marked('other')] }])).toBe(emails);
|
||||
});
|
||||
|
||||
it("keeps each account's snippets to its own rows, since ids repeat across accounts", () => {
|
||||
const emails = [email('a', 'own'), email('a', 'group')];
|
||||
const next = withSearchSnippets(emails, [{ accountId: 'group', list: [marked('a')] }]);
|
||||
|
||||
expect(next[0]).toBe(emails[0]);
|
||||
expect(next[1].searchSnippet?.subject).toBe('<mark>Subject</mark> a');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { itemsToHide, type MeasuredToolbarItem } from '../toolbar-overflow';
|
||||
|
||||
// The viewer toolbar hides its least important items until it fits. It used
|
||||
// to hide one item, re-read the layout, and repeat; itemsToHide decides the
|
||||
// same from one measurement. The reference below is that loop, run against a
|
||||
// flex-row model (a group is its visible items plus a gap between each two).
|
||||
|
||||
type Item = MeasuredToolbarItem;
|
||||
|
||||
function groupWidth(items: Item[], hidden: Set<number>, group: string, gap: number): number {
|
||||
const shown = items.filter((it, i) => it.group === group && it.width > 0 && !hidden.has(i));
|
||||
return shown.reduce((sum, it) => sum + it.width, 0) + Math.max(0, shown.length - 1) * gap;
|
||||
}
|
||||
|
||||
function hideOneByOne(items: Item[], fixed: Record<string, number>, gaps: Record<string, number>, available: number): number[] {
|
||||
const hidden = new Set<number>();
|
||||
const width = () => Object.keys(fixed).reduce((sum, g) => sum + fixed[g] + groupWidth(items, hidden, g, gaps[g]), 0);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (width() <= available) break;
|
||||
if (items[i].width === 0) continue;
|
||||
hidden.add(i);
|
||||
}
|
||||
return [...hidden];
|
||||
}
|
||||
|
||||
function decideAtOnce(items: Item[], fixed: Record<string, number>, gaps: Record<string, number>, available: number): number[] {
|
||||
const width = Object.keys(fixed).reduce((sum, g) => sum + fixed[g] + groupWidth(items, new Set(), g, gaps[g]), 0);
|
||||
return itemsToHide(items, width - available, gaps);
|
||||
}
|
||||
|
||||
describe('itemsToHide', () => {
|
||||
const gaps: Record<string, number> = { left: 4, right: 8 };
|
||||
const fixed: Record<string, number> = { left: 0, right: 0 };
|
||||
const items: Item[] = [
|
||||
{ width: 32, group: 'right' },
|
||||
{ width: 0, group: 'right' }, // hidden by CSS
|
||||
{ width: 90, group: 'left' },
|
||||
{ width: 32, group: 'right' },
|
||||
{ width: 60, group: 'left' },
|
||||
{ width: 32, group: 'right' },
|
||||
];
|
||||
|
||||
it('hides nothing when the toolbar fits', () => {
|
||||
expect(itemsToHide(items, 0, gaps)).toEqual([]);
|
||||
expect(itemsToHide(items, -40, gaps)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips items CSS already hides', () => {
|
||||
expect(itemsToHide(items, 50, gaps)).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
it('agrees with hiding one item at a time and re-measuring', () => {
|
||||
const full = Object.keys(fixed).reduce((sum, g) => sum + groupWidth(items, new Set(), g, gaps[g]), 0);
|
||||
for (let available = 0; available <= full + 10; available++) {
|
||||
expect(decideAtOnce(items, fixed, gaps, available)).toEqual(hideOneByOne(items, fixed, gaps, available));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -117,6 +117,28 @@ describe('searchAcrossAccounts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deferred snippets across accounts', () => {
|
||||
it("asks every account to defer and hands back each account's snippets under its id", async () => {
|
||||
const ownSearch = vi.fn(async () => ({ ...page([email('a', '2026-09-10T00:00:00Z', 'inbox')]), snippets: Promise.resolve([{ emailId: 'a', subject: '<mark>own</mark>' }]) }));
|
||||
const groupSearch = vi.fn(async () => ({ ...page([email('a', '2026-09-12T00:00:00Z', 'group:g-inbox')]), snippets: Promise.resolve(undefined) }));
|
||||
const own = makeAccount({ accountId: 'login', jmapAccountId: 'me', mailboxes: ownMailboxes }, { searchEmails: ownSearch });
|
||||
const group = makeAccount({ accountId: 'group', isShared: true, mailboxes: groupMailboxes }, { searchEmails: groupSearch });
|
||||
|
||||
const result = await searchAcrossAccounts([own, group], 'x', 50, 0, { deferSnippets: true });
|
||||
|
||||
expect(ownSearch).toHaveBeenCalledWith('x', undefined, undefined, 50, 0, { deferSnippets: true });
|
||||
expect(groupSearch).toHaveBeenCalledWith('x', undefined, 'group', 50, 0, { deferSnippets: true });
|
||||
// A failed or empty snippet request of one account drops out.
|
||||
expect(await result.snippets).toEqual([{ accountId: 'login', list: [{ emailId: 'a', subject: '<mark>own</mark>' }] }]);
|
||||
});
|
||||
|
||||
it('carries no snippets when no account deferred any', async () => {
|
||||
const own = makeAccount({ accountId: 'login' }, { searchEmails: vi.fn(async () => page([])) });
|
||||
const result = await searchAcrossAccounts([own], 'x', 50, 0);
|
||||
expect(result.snippets).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('advancedSearchAcrossAccounts', () => {
|
||||
it('sends the same folder-less filter to every account at the requested page', async () => {
|
||||
const filter = { operator: 'AND', conditions: [{ text: 'acesso*' }, { from: 'bob@example.com' }] };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
findVerificationCode,
|
||||
htmlMayNameACode,
|
||||
listVerificationCode,
|
||||
verificationCodeBodyText,
|
||||
VERIFICATION_CODE_MAX_AGE_MS,
|
||||
@@ -145,6 +146,30 @@ describe('verificationCodeBodyText', () => {
|
||||
expect(findVerificationCode('Verification Code', text)).toBe('261765');
|
||||
});
|
||||
|
||||
// Opening a big HTML mail spent most of its script time turning the HTML
|
||||
// into text to look for a code. Without any word that names a code there is
|
||||
// nothing to find, so that step is skipped; it must never skip a mail that
|
||||
// has one, however the word is written.
|
||||
const html = (body: string) => verificationCodeBodyText({
|
||||
htmlBody: [part('1', 'text/html')],
|
||||
textBody: [],
|
||||
bodyValues: { 1: value(`<html><body>${body}</body></html>`) },
|
||||
});
|
||||
|
||||
it('skips reading HTML that names no code', () => {
|
||||
const newsletter = '<h2>Vacation policy update #998</h2><table><tr><td>row-1</td><td>0.16487150</td></tr></table><p>Lorem ipsum 482910 commodo</p>';
|
||||
expect(htmlMayNameACode(newsletter)).toBe(false);
|
||||
expect(html(newsletter)).toBe('');
|
||||
});
|
||||
|
||||
it('still reads HTML whose code word is marked up, escaped or padded', () => {
|
||||
expect(findVerificationCode('', html('Your verification <b>code</b> is 482910'))).toBe('482910');
|
||||
expect(findVerificationCode('', html('Tu código de verificación es 482910'))).toBe('482910');
|
||||
expect(findVerificationCode('', html('Ваш код: 482910'))).toBe('482910');
|
||||
expect(findVerificationCode('', html('Your verification co­de is 482910'))).toBe('482910');
|
||||
expect(findVerificationCode('', html('Ihre mTAN lautet 482910'))).toBe('482910');
|
||||
});
|
||||
|
||||
it('reads plain-text mail', () => {
|
||||
const text = verificationCodeBodyText({
|
||||
htmlBody: [part('1', 'text/plain')],
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Runs `fn` in a task of its own, once the current one has ended.
|
||||
*
|
||||
* Chromium hands a fetch() to the network only when the task that called it
|
||||
* yields, so a request issued from a click or key handler otherwise leaves
|
||||
* the browser after the handler's whole synchronous render pass: tens of
|
||||
* milliseconds for a tree this size. Issuing the request first and writing
|
||||
* the state that only paints a loading indicator from here lets the round
|
||||
* trip overlap that rendering instead of following it. Outside a browser it
|
||||
* runs `fn` at once.
|
||||
*/
|
||||
export function afterRequestDispatch(fn: () => void): void {
|
||||
if (typeof window === 'undefined') {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
window.setTimeout(fn, 0);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
|
||||
/**
|
||||
* Message bodies requested ahead of the click that opens them.
|
||||
*
|
||||
* A mouse press on a row comes roughly a tenth of a second before the click
|
||||
* that follows it, and Chromium sends a fetch() only once the task that made
|
||||
* it has ended: the click's task ends after its whole render pass, the
|
||||
* press's task at once. So the body is requested on the press and the open
|
||||
* takes over the request already on its way. A request nobody claims within
|
||||
* PREFETCH_TTL_MS is dropped (the press may have started a drag or a
|
||||
* selection instead).
|
||||
*/
|
||||
const PREFETCH_TTL_MS = 5_000;
|
||||
const pending = new Map<string, { at: number; email: Promise<Email | null> }>();
|
||||
|
||||
const keyFor = (client: IJMAPClient, emailId: string, accountId: string | undefined) =>
|
||||
`${client.getAccountId()}|${accountId ?? ""}|${emailId}`;
|
||||
|
||||
function dropStale(now: number): void {
|
||||
for (const [key, entry] of pending) if (now - entry.at > PREFETCH_TTL_MS) pending.delete(key);
|
||||
}
|
||||
|
||||
/** Starts fetching a message's content, unless that request is already on its way. */
|
||||
export function prefetchEmailContent(client: IJMAPClient, emailId: string, accountId?: string): void {
|
||||
const now = Date.now();
|
||||
dropStale(now);
|
||||
const key = keyFor(client, emailId, accountId);
|
||||
if (pending.has(key)) return;
|
||||
const email = client.getEmail(emailId, accountId);
|
||||
// A prefetch nobody claims must not surface as an unhandled rejection; the
|
||||
// open that claims it still sees the failure.
|
||||
email.catch(() => {});
|
||||
pending.set(key, { at: now, email });
|
||||
}
|
||||
|
||||
/** The message's content: the request a press already started, else a new one. */
|
||||
export function requestEmailContent(client: IJMAPClient, emailId: string, accountId?: string): Promise<Email | null> {
|
||||
const key = keyFor(client, emailId, accountId);
|
||||
const entry = pending.get(key);
|
||||
pending.delete(key);
|
||||
if (entry && Date.now() - entry.at <= PREFETCH_TTL_MS) return entry.email;
|
||||
return client.getEmail(emailId, accountId);
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function forgetEmailPrefetches(): void {
|
||||
pending.clear();
|
||||
}
|
||||
@@ -14,6 +14,14 @@ export interface FolderList {
|
||||
emails: Email[];
|
||||
totalEmails: number;
|
||||
hasMoreEmails: boolean;
|
||||
/**
|
||||
* The Email state the rows were current at, for a plain folder list. A
|
||||
* revisit then asks the server only what changed since (Email/changes)
|
||||
* instead of re-reading the page, and keeps its rows when nothing did.
|
||||
*/
|
||||
sync?: { state: string; accountId: string; mailboxId: string } | null;
|
||||
/** Conversation sizes of the rows, which a folder switch clears. */
|
||||
threadEmailCounts?: Map<string, number>;
|
||||
}
|
||||
|
||||
/** Folders remembered per tab; the oldest visited is dropped first. */
|
||||
@@ -55,6 +63,8 @@ export function rememberFolderList(key: string, list: FolderList, pageSize: numb
|
||||
emails: list.emails.slice(0, pageSize),
|
||||
totalEmails: list.totalEmails,
|
||||
hasMoreEmails: list.hasMoreEmails || list.emails.length > pageSize,
|
||||
sync: list.sync ?? null,
|
||||
threadEmailCounts: list.threadEmailCounts,
|
||||
});
|
||||
while (lists.size > MAX_FOLDERS) {
|
||||
const oldest = lists.keys().next().value;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountSta
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { FileNameRules } from "@/lib/file-name-rules";
|
||||
import type { SortLevel } from "@/lib/message-list-order";
|
||||
import type { SearchSnippetResult } from "@/lib/search-snippet";
|
||||
|
||||
/** What `migrateKeyword` managed to do. */
|
||||
export interface KeywordMigration {
|
||||
@@ -48,6 +49,24 @@ export interface CalendarEventUpdateOptions {
|
||||
keepSequence?: boolean;
|
||||
}
|
||||
|
||||
/** How a search request fetches its SearchSnippet/get highlights. */
|
||||
export interface SearchRequestOptions {
|
||||
/**
|
||||
* Fetch the snippets in a follow-up request instead of chaining them onto
|
||||
* the search, and hand them back as `snippets`. For interactive searches,
|
||||
* where the list must not wait for the highlighting.
|
||||
*/
|
||||
deferSnippets?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchEmailsResult {
|
||||
emails: Email[];
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
/** Set only with `deferSnippets`, when the filter has a term to highlight. */
|
||||
snippets?: Promise<SearchSnippetResult[] | undefined>;
|
||||
}
|
||||
|
||||
export interface IJMAPClient {
|
||||
// ── Connection lifecycle ──────────────────────────────────────
|
||||
connect(): Promise<void>;
|
||||
@@ -176,7 +195,11 @@ export interface IJMAPClient {
|
||||
// server-side after pinned-first; see lib/message-list-order.ts.
|
||||
// `state` is the Email collection state the page was read at (RFC 8620
|
||||
// §5.1), when the client reports it; the store uses it for Email/changes.
|
||||
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order?: SortLevel[]): Promise<{ emails: Email[]; hasMore: boolean; total: number; state?: string }>;
|
||||
/**
|
||||
* A page of a view. `threadCounts` (message count per thread id) comes with
|
||||
* the page when the server answers the Thread/get chained onto it.
|
||||
*/
|
||||
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order?: SortLevel[]): Promise<{ emails: Email[]; hasMore: boolean; total: number; state?: string; threadCounts?: Record<string, number> }>;
|
||||
/**
|
||||
* Sort properties the account's server advertises for Email/query
|
||||
* (`emailQuerySortOptions` in the mail capability, RFC 8621 §1.3), or null
|
||||
@@ -224,13 +247,14 @@ export interface IJMAPClient {
|
||||
}): Promise<{ keywords: Record<string, number>; scanned: number; total: number; complete: boolean }>;
|
||||
/** Per-tab unread counts for message-list category tabs (filter = resolved tab fragment, null = unfiltered). */
|
||||
getCategoryUnreadCounts(mailboxId: string, tabs: Array<{ id: string; filter: Record<string, unknown> | null }>, accountId?: string): Promise<Record<string, number>>;
|
||||
searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||
searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number, options?: SearchRequestOptions): Promise<SearchEmailsResult>;
|
||||
advancedSearchEmails(
|
||||
filter: Record<string, unknown>,
|
||||
accountId?: string,
|
||||
limit?: number,
|
||||
position?: number,
|
||||
): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||
options?: SearchRequestOptions,
|
||||
): Promise<SearchEmailsResult>;
|
||||
/**
|
||||
* Lean recipient search for compose autocomplete ("search the server" action):
|
||||
* finds messages in `sentMailboxId` whose to/cc matches `query` and returns
|
||||
|
||||
+66
-11
@@ -1,7 +1,7 @@
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailPushConfig, DeliveryStatus, EmailSubmission, RejectedRecipient, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { CalendarEventUpdateOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface";
|
||||
import type { CalendarEventUpdateOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration, SearchEmailsResult, SearchRequestOptions } from "./client-interface";
|
||||
import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet";
|
||||
import { batched, itemsPerRequest } from "./request-limits";
|
||||
import { keywordPointer } from "./patch-pointer";
|
||||
@@ -27,6 +27,10 @@ import { toInertBlob } from "@/lib/file-preview";
|
||||
// / #884). Bounded well above the default so it stays rare in practice while
|
||||
// still guarding against unbounded memory use on a malicious/oversized body.
|
||||
const TRUNCATED_BODY_REFETCH_MAX_BYTES = 8_000_000;
|
||||
// Opening one message needs its whole body, so its fetch takes more than the
|
||||
// list-sized cap: a newsletter just over 256 kB came back truncated and was
|
||||
// fetched a second time. Larger bodies still take the refetch above.
|
||||
const OPEN_EMAIL_MAX_BODY_BYTES = 1_000_000;
|
||||
|
||||
// Names of nodes created over WebDAV come back percent-encoded from
|
||||
// FileNode/get (see decodeFileNodeName / #869). Normalize at the boundary so
|
||||
@@ -674,6 +678,20 @@ const FILE_NODE_QUERY_PAGE = 5000;
|
||||
/** Safety bound on how many FileNode ids one listing pages through. */
|
||||
const FILE_NODE_MAX_IDS = 200_000;
|
||||
|
||||
/**
|
||||
* Message counts per thread id from the Thread/get chained onto a list
|
||||
* request; undefined when the server did not answer it (one that cannot
|
||||
* resolve a wildcard back-reference fails just that call).
|
||||
*/
|
||||
function threadCountsFrom(methodResponse: unknown): Record<string, number> | undefined {
|
||||
if (!Array.isArray(methodResponse) || methodResponse[0] !== "Thread/get") return undefined;
|
||||
const list = (methodResponse[1] as { list?: Thread[] } | undefined)?.list;
|
||||
if (!Array.isArray(list)) return undefined;
|
||||
const counts: Record<string, number> = {};
|
||||
for (const thread of list) counts[thread.id] = thread.emailIds?.length ?? 0;
|
||||
return counts;
|
||||
}
|
||||
|
||||
function computeHasMore(position: number, emailCount: number, total: number, limit: number): boolean {
|
||||
if (total > 0) return (position + emailCount) < total;
|
||||
return emailCount === limit;
|
||||
@@ -1991,7 +2009,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
// Throws when the page cannot be read. Answering with an empty page made a
|
||||
// failed refresh empty the list, and callers could not tell it from an
|
||||
// empty folder.
|
||||
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order: SortLevel[] = []): Promise<{ emails: Email[], hasMore: boolean, total: number, state?: string }> {
|
||||
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order: SortLevel[] = []): Promise<{ emails: Email[], hasMore: boolean, total: number, state?: string, threadCounts?: Record<string, number> }> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const simple: { inMailbox?: string; hasKeyword?: string } = {};
|
||||
if (mailboxId) {
|
||||
@@ -2030,6 +2048,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
|
||||
properties: [...EMAIL_LIST_PROPERTIES],
|
||||
}, "1"],
|
||||
// The page's conversation sizes, off the thread ids the Email/get
|
||||
// returns (RFC 8620 §3.7 wildcard path): no second round trip, and the
|
||||
// list paints with its counts instead of re-rendering for them.
|
||||
["Thread/get", {
|
||||
accountId: targetAccountId,
|
||||
"#ids": { resultOf: "1", name: "Email/get", path: "/list/*/threadId" },
|
||||
}, "2"],
|
||||
]);
|
||||
|
||||
let response = await query(built.sort);
|
||||
@@ -2064,7 +2089,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
// The Email collection state this page was read at; the store keeps
|
||||
// it so a later push can be applied with Email/changes.
|
||||
const state = typeof getResponse.state === 'string' ? getResponse.state : undefined;
|
||||
return { emails, hasMore, total, state };
|
||||
return { emails, hasMore, total, state, threadCounts: threadCountsFrom(response.methodResponses?.[2]) };
|
||||
}
|
||||
|
||||
throw new Error(methodErrorMessage(response, 'Failed to get emails'));
|
||||
@@ -2348,7 +2373,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
fetchTextBodyValues: true,
|
||||
fetchHTMLBodyValues: true,
|
||||
fetchAllBodyValues: true,
|
||||
maxBodyValueBytes: 256000,
|
||||
maxBodyValueBytes: OPEN_EMAIL_MAX_BODY_BYTES,
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
@@ -3290,7 +3315,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||||
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, options: SearchRequestOptions = {}): Promise<SearchEmailsResult> {
|
||||
try {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
@@ -3326,7 +3351,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
|
||||
properties: [...EMAIL_LIST_PROPERTIES],
|
||||
}, "1"],
|
||||
this.searchSnippetCall(targetAccountId, filter),
|
||||
...(options.deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, filter)]),
|
||||
]);
|
||||
assertQuerySucceeded(response, 'Search');
|
||||
|
||||
@@ -3346,7 +3371,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
namespaceMailboxIds(emails, accountId);
|
||||
}
|
||||
|
||||
return { emails, hasMore, total };
|
||||
return { emails, hasMore, total, ...this.deferredSnippets(options, targetAccountId, filter, emails) };
|
||||
} catch (error) {
|
||||
// Rethrow: an empty result would read as "No results found".
|
||||
console.error('Search failed:', error);
|
||||
@@ -3372,6 +3397,35 @@ export class JMAPClient implements IJMAPClient {
|
||||
}, "snippets"];
|
||||
}
|
||||
|
||||
/**
|
||||
* With `deferSnippets`, the snippets of a result page as a request of their
|
||||
* own, already in flight. Chained onto the search they cost no round trip
|
||||
* but hold the whole response back while the server excerpts every hit,
|
||||
* several times longer than the query itself, so an interactive search
|
||||
* lists its results first and highlights them when this resolves. Never
|
||||
* rejects: a failure leaves the results without snippets, as it does when
|
||||
* chained.
|
||||
*/
|
||||
private deferredSnippets(
|
||||
options: SearchRequestOptions,
|
||||
accountId: string,
|
||||
filter: Record<string, unknown> | undefined,
|
||||
emails: Email[],
|
||||
): Pick<SearchEmailsResult, 'snippets'> {
|
||||
if (!options.deferSnippets || emails.length === 0 || !filter || !filterHasSnippetTerms(filter)) return {};
|
||||
const snippets = this.request([
|
||||
["SearchSnippet/get", {
|
||||
accountId,
|
||||
filter: snippetFilterFor(filter),
|
||||
emailIds: emails.map((email) => email.id),
|
||||
}, "snippets"],
|
||||
]).then((response) => this.searchSnippetsFrom(response)).catch((error) => {
|
||||
debug.warn("jmap", "SearchSnippet/get failed:", error);
|
||||
return undefined;
|
||||
});
|
||||
return { snippets };
|
||||
}
|
||||
|
||||
/** The SearchSnippet list of a search response, if the call succeeded. */
|
||||
private searchSnippetsFrom(response: JMAPResponse): SearchSnippetResult[] | undefined {
|
||||
for (const [method, result, callId] of response.methodResponses ?? []) {
|
||||
@@ -3386,8 +3440,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
filter: Record<string, unknown>,
|
||||
accountId?: string,
|
||||
limit: number = 50,
|
||||
position: number = 0
|
||||
): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||||
position: number = 0,
|
||||
options: SearchRequestOptions = {},
|
||||
): Promise<SearchEmailsResult> {
|
||||
try {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
@@ -3409,7 +3464,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
|
||||
properties: [...EMAIL_LIST_PROPERTIES],
|
||||
}, "1"],
|
||||
this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined),
|
||||
...(options.deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined)]),
|
||||
]);
|
||||
assertQuerySucceeded(response, 'Search');
|
||||
|
||||
@@ -3429,7 +3484,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
namespaceMailboxIds(emails, accountId);
|
||||
}
|
||||
|
||||
return { emails, hasMore, total };
|
||||
return { emails, hasMore, total, ...this.deferredSnippets(options, targetAccountId, hasFilter ? filter : undefined, emails) };
|
||||
} catch (error) {
|
||||
console.error('Advanced search failed:', error);
|
||||
throw error;
|
||||
|
||||
@@ -109,6 +109,43 @@ export function snippetFilterFor(filter: Record<string, unknown>): Record<string
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snippets fetched after their results were listed (see
|
||||
* SearchRequestOptions.deferSnippets), with the account they were asked in.
|
||||
* Ids repeat across accounts, so a search fanned out over several keeps each
|
||||
* account's snippets to its own rows; no `accountId` matches every row.
|
||||
*/
|
||||
export interface DeferredSnippetBatch {
|
||||
accountId?: string;
|
||||
list: SearchSnippetResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* attachSearchSnippets for a list that is already on screen: returns a new
|
||||
* list with new objects for the emails that gained a snippet, and the same
|
||||
* list when none did, so a store update re-renders only the rows that changed.
|
||||
*/
|
||||
export function withSearchSnippets(emails: Email[], batches: DeferredSnippetBatch[]): Email[] {
|
||||
const lookups = batches
|
||||
.filter((batch) => batch.list.length > 0)
|
||||
.map((batch) => ({ accountId: batch.accountId, byId: new Map(batch.list.map((s) => [s.emailId, s])) }));
|
||||
if (lookups.length === 0) return emails;
|
||||
let changed = false;
|
||||
const next = emails.map((email) => {
|
||||
for (const { accountId, byId } of lookups) {
|
||||
if (accountId !== undefined && email.accountId !== accountId) continue;
|
||||
const snippet = byId.get(email.id);
|
||||
if (!snippet) continue;
|
||||
const [withSnippet] = attachSearchSnippets([{ ...email }], [snippet]);
|
||||
if (!withSnippet.searchSnippet) return email;
|
||||
changed = true;
|
||||
return withSnippet;
|
||||
}
|
||||
return email;
|
||||
});
|
||||
return changed ? next : emails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches the snippets to the emails they belong to. A snippet without any
|
||||
* highlighted term (the server found nothing to mark, or returned the plain
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/** One toolbar item as measured with every item shown. */
|
||||
export interface MeasuredToolbarItem {
|
||||
/** Rendered width; 0 when CSS already hides it (e.g. on mobile). */
|
||||
width: number;
|
||||
/** The flex group it sits in, to give back that group's gap when hidden. */
|
||||
group: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which items to hide so the toolbar fits, decided from one measuring pass.
|
||||
* `items` come least important first; they are hidden in that order until
|
||||
* `overflow` (content width minus available width, in px) is used up. A
|
||||
* hidden item frees its width plus one gap of its group, unless it was the
|
||||
* last visible item there. Returns the indexes into `items` to hide.
|
||||
*
|
||||
* The toolbar used to hide an item and re-read the layout after each one,
|
||||
* which forced a layout per item on every open.
|
||||
*/
|
||||
export function itemsToHide(
|
||||
items: MeasuredToolbarItem[],
|
||||
overflow: number,
|
||||
gapOf: Record<string, number>,
|
||||
): number[] {
|
||||
const visible: Record<string, number> = {};
|
||||
for (const item of items) if (item.width > 0) visible[item.group] = (visible[item.group] ?? 0) + 1;
|
||||
const hide: number[] = [];
|
||||
let remaining = overflow;
|
||||
items.forEach((item, i) => {
|
||||
if (remaining <= 0 || item.width === 0) return;
|
||||
const left = visible[item.group] ?? 1;
|
||||
remaining -= item.width + (left > 1 ? (gapOf[item.group] ?? 0) : 0);
|
||||
visible[item.group] = left - 1;
|
||||
hide.push(i);
|
||||
});
|
||||
return hide;
|
||||
}
|
||||
+23
-6
@@ -1,6 +1,7 @@
|
||||
import type { Email, Mailbox, UnifiedMailboxRole, CrossView } from '@/lib/jmap/types';
|
||||
import { CROSS_EXCLUDED_ROLES } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { IJMAPClient, SearchEmailsResult, SearchRequestOptions } from '@/lib/jmap/client-interface';
|
||||
import type { DeferredSnippetBatch } from '@/lib/search-snippet';
|
||||
import { andFilters } from '@/lib/jmap/search-utils';
|
||||
import { compareEmails, type SortLevel } from '@/lib/message-list-order';
|
||||
|
||||
@@ -43,6 +44,8 @@ export interface UnifiedFetchResult {
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
errors: Map<string, string>; // accountId -> error message
|
||||
/** Deferred search snippets per account (see SearchRequestOptions.deferSnippets). */
|
||||
snippets?: Promise<DeferredSnippetBatch[]>;
|
||||
}
|
||||
|
||||
export interface UnifiedMailboxCounts {
|
||||
@@ -591,7 +594,7 @@ export async function fetchTagEmails(
|
||||
);
|
||||
}
|
||||
|
||||
export interface AcrossAccountsSearchOptions {
|
||||
export interface AcrossAccountsSearchOptions extends SearchRequestOptions {
|
||||
/**
|
||||
* Leave every account's Trash and Junk out: the search panel's default
|
||||
* scope, "All folders except Spam and Trash". Its "All folders" scope
|
||||
@@ -624,8 +627,9 @@ export async function searchAcrossAccounts(
|
||||
return exclusion
|
||||
? account.client.advancedSearchEmails(
|
||||
andFilters({ text: query.trim() }, exclusion), jmapAccountId, limit, positionFor(position, account),
|
||||
...searchRequestOptions(options),
|
||||
)
|
||||
: account.client.searchEmails(query, undefined, jmapAccountId, limit, positionFor(position, account));
|
||||
: account.client.searchEmails(query, undefined, jmapAccountId, limit, positionFor(position, account), ...searchRequestOptions(options));
|
||||
},
|
||||
newestFirst,
|
||||
);
|
||||
@@ -649,11 +653,17 @@ export async function advancedSearchAcrossAccounts(
|
||||
jmapAccountId,
|
||||
limit,
|
||||
positionFor(position, account),
|
||||
...searchRequestOptions(options),
|
||||
),
|
||||
newestFirst,
|
||||
);
|
||||
}
|
||||
|
||||
/** The trailing SearchRequestOptions argument, left off when nothing is asked of it. */
|
||||
function searchRequestOptions(options: AcrossAccountsSearchOptions): [] | [SearchRequestOptions] {
|
||||
return options.deferSnippets ? [{ deferSnippets: true }] : [];
|
||||
}
|
||||
|
||||
function newestFirst(a: Email, b: Email): number {
|
||||
return new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime();
|
||||
}
|
||||
@@ -669,14 +679,14 @@ async function fanOutAccountQuery(
|
||||
run: (
|
||||
account: UnifiedAccountClient,
|
||||
jmapAccountId: string | undefined,
|
||||
) => Promise<{ emails: Email[]; total: number; hasMore: boolean }>,
|
||||
) => Promise<SearchEmailsResult>,
|
||||
sort: (a: Email, b: Email) => number,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
const errors = new Map<string, string>();
|
||||
|
||||
type AccountResult = {
|
||||
account: UnifiedAccountClient;
|
||||
result: { emails: Email[]; total: number; hasMore: boolean };
|
||||
result: SearchEmailsResult;
|
||||
} | null;
|
||||
|
||||
const promises = accounts.map(async (account): Promise<AccountResult> => {
|
||||
@@ -695,10 +705,14 @@ async function fanOutAccountQuery(
|
||||
let mergedEmails: Email[] = [];
|
||||
let totalSum = 0;
|
||||
let anyHasMore = false;
|
||||
const snippetBatches: Promise<DeferredSnippetBatch | null>[] = [];
|
||||
|
||||
for (const outcome of results) {
|
||||
if (outcome.status !== 'fulfilled' || outcome.value === null) continue;
|
||||
const { account, result } = outcome.value;
|
||||
if (result.snippets) {
|
||||
snippetBatches.push(result.snippets.then((list) => (list ? { accountId: account.accountId, list } : null)));
|
||||
}
|
||||
// Decorate shallow copies, not the shared client-returned objects. The
|
||||
// source stamps are what routes every later action (read, move, delete,
|
||||
// thread expansion) back to the owning account.
|
||||
@@ -717,7 +731,10 @@ async function fanOutAccountQuery(
|
||||
|
||||
mergedEmails.sort(sort);
|
||||
|
||||
return { emails: mergedEmails, total: totalSum, hasMore: anyHasMore, errors };
|
||||
const snippets = snippetBatches.length > 0
|
||||
? Promise.all(snippetBatches).then((batches) => batches.filter((batch): batch is DeferredSnippetBatch => batch !== null))
|
||||
: undefined;
|
||||
return { emails: mergedEmails, total: totalSum, hasMore: anyHasMore, errors, ...(snippets ? { snippets } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+31
-12
@@ -69,6 +69,25 @@ function resolveDateLocale<T extends string | undefined>(
|
||||
}
|
||||
}
|
||||
|
||||
const dateFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
/**
|
||||
* An Intl.DateTimeFormat for `locale` and `options`, built once. Each
|
||||
* toLocale*String call constructs one afresh, and the list formats every
|
||||
* row's date on every render. Only for option sets that name a date or time
|
||||
* field: then toLocaleString, toLocaleDateString and toLocaleTimeString all
|
||||
* format exactly as the formatter does.
|
||||
*/
|
||||
function cachedDateFormat(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
||||
const key = `${locale}|${JSON.stringify(options)}`;
|
||||
let formatter = dateFormatters.get(key);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat(locale, options);
|
||||
dateFormatters.set(key, formatter);
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a received-at date for the email list. The output style is
|
||||
* controlled by the `dateFormat` user setting:
|
||||
@@ -131,16 +150,16 @@ export function formatDate(
|
||||
if (minutes < 60) return rtf.format(-minutes, "minute");
|
||||
if (hours < 24) return rtf.format(-hours, "hour");
|
||||
if (days < 7) return rtf.format(-days, "day");
|
||||
return d.toLocaleDateString(uiLocale, {
|
||||
return cachedDateFormat(uiLocale, {
|
||||
timeZone,
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: getWallClock(d, timeZone).year !== getWallClock(now, timeZone).year ? "numeric" : undefined,
|
||||
});
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
if (dateFormat === "full") {
|
||||
return d.toLocaleString(numericLocale, {
|
||||
return cachedDateFormat(numericLocale, {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
@@ -148,16 +167,16 @@ export function formatDate(
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12,
|
||||
});
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
// 'smart' (default)
|
||||
const timeStr = d.toLocaleTimeString(uiLocale, {
|
||||
const timeStr = cachedDateFormat(uiLocale, {
|
||||
timeZone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12,
|
||||
});
|
||||
}).format(d);
|
||||
|
||||
// Calendar-day comparison in the display zone, not the browser zone.
|
||||
const dWall = getWallClock(d, timeZone);
|
||||
@@ -170,27 +189,27 @@ export function formatDate(
|
||||
|
||||
if (overrides?.listStyle === 'mountain-view') {
|
||||
if (dWall.year === nowWall.year) {
|
||||
return d.toLocaleDateString(uiLocale, { timeZone, month: "short", day: "numeric" });
|
||||
return cachedDateFormat(uiLocale, { timeZone, month: "short", day: "numeric" }).format(d);
|
||||
}
|
||||
return d.toLocaleDateString(numericLocale, { timeZone, year: "2-digit", month: "numeric", day: "numeric" });
|
||||
return cachedDateFormat(numericLocale, { timeZone, year: "2-digit", month: "numeric", day: "numeric" }).format(d);
|
||||
}
|
||||
|
||||
const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000);
|
||||
if (daysAgo < 7) {
|
||||
// German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;
|
||||
// strip it so the result reads cleanly next to the time.
|
||||
const weekday = d
|
||||
.toLocaleDateString(uiLocale, { timeZone, weekday: "short" })
|
||||
const weekday = cachedDateFormat(uiLocale, { timeZone, weekday: "short" })
|
||||
.format(d)
|
||||
.replace(/\.$/, "");
|
||||
return `${weekday} ${timeStr}`;
|
||||
}
|
||||
|
||||
return d.toLocaleDateString(numericLocale, {
|
||||
return cachedDateFormat(numericLocale, {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -290,13 +290,50 @@ export function listVerificationCode(
|
||||
const MAX_BODY_HTML = 200_000;
|
||||
const MAX_BODY_TEXT = 50_000;
|
||||
|
||||
// Every code word above contains one of these (any case), so a body without
|
||||
// any of them has no code to find, and the HTML need not be turned into text.
|
||||
const CODE_WORD_HINT = /cod|cód|kod|kód|kood|код|κωδικ|קוד|رمز|کد|kennwort|passwort|pass(?:word|code)|コード|認証番号|確認番号|验证码|驗證碼|校验码|动态码|인증|코드/iu;
|
||||
const UPPERCASE_HINT = /PIN|OTP|TAN/;
|
||||
// Entities that stand for no letter, decoded so they can't hide a code word.
|
||||
const NAMED_ENTITIES: Record<string, string> = {
|
||||
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ensp: ' ', emsp: ' ', thinsp: ' ',
|
||||
shy: '', zwnj: '', zwj: '', lrm: '', rlm: '', copy: '', reg: '', trade: '', hellip: '', mdash: '',
|
||||
ndash: '', lsquo: '', rsquo: '', ldquo: '', rdquo: '', laquo: '', raquo: '', bull: '', middot: '',
|
||||
euro: '', pound: '', cent: '', yen: '', deg: '', times: '', sect: '', para: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* False when the HTML cannot hold a code word, so verificationCodeBodyText
|
||||
* can skip parsing it (the costly part of opening a big HTML mail). Errs on
|
||||
* the side of true: tags are dropped so a word split by markup joins up
|
||||
* again, numeric entities are decoded, invisible spacers removed, and any
|
||||
* named entity that might be a letter counts as a possible code word.
|
||||
*/
|
||||
export function htmlMayNameACode(html: string): boolean {
|
||||
let unknownEntity = false;
|
||||
const text = html
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (_m, ref: string) => {
|
||||
if (ref[0] === '#') {
|
||||
const cp = ref[1] === 'x' || ref[1] === 'X' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
|
||||
return cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : '';
|
||||
}
|
||||
const known = NAMED_ENTITIES[ref.toLowerCase()];
|
||||
if (known === undefined) unknownEntity = true;
|
||||
return known ?? '';
|
||||
})
|
||||
.replace(/[\u00ad\u200b-\u200f\u2060\ufeff]|\u034f/g, '');
|
||||
return unknownEntity || CODE_WORD_HINT.test(text) || UPPERCASE_HINT.test(text);
|
||||
}
|
||||
|
||||
/** The text of a loaded mail as the reader shows it, for finding its code. */
|
||||
export function verificationCodeBodyText(email: Pick<Email, 'htmlBody' | 'textBody' | 'bodyValues'>): string {
|
||||
const html = getRenderableHtmlBody(email);
|
||||
if (html) {
|
||||
// htmlToPlainText walks the body, and a <style> in it is text there.
|
||||
const markup = html.replace(/<(style|script|title)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ');
|
||||
return htmlToPlainText(markup.slice(0, MAX_BODY_HTML)).slice(0, MAX_BODY_TEXT);
|
||||
const markup = html.replace(/<(style|script|title)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ').slice(0, MAX_BODY_HTML);
|
||||
if (!htmlMayNameACode(markup)) return '';
|
||||
return htmlToPlainText(markup).slice(0, MAX_BODY_TEXT);
|
||||
}
|
||||
const textPart = email.textBody?.[0]?.partId;
|
||||
return ((textPart && email.bodyValues?.[textPart]?.value) || '').slice(0, MAX_BODY_TEXT);
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"lite:serve": "node scripts/lite/serve.mjs out 4173",
|
||||
"test:lite-smoke": "playwright test -c scripts/lite/smoke/playwright.config.ts",
|
||||
"test:lite-smoke:stalwart": "playwright test -c scripts/lite/smoke/playwright.stalwart.config.ts",
|
||||
"test:perf-guard": "playwright test -c scripts/perf-guard/playwright.config.ts",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"open": {
|
||||
"commits": 8,
|
||||
"renders": 428,
|
||||
"requests": 1
|
||||
},
|
||||
"search": {
|
||||
"commits": 9,
|
||||
"renders": 410,
|
||||
"requests": 2
|
||||
},
|
||||
"folder (first visit)": {
|
||||
"commits": 8,
|
||||
"renders": 488,
|
||||
"requests": 2
|
||||
},
|
||||
"folder (revisit)": {
|
||||
"commits": 3,
|
||||
"renders": 275,
|
||||
"requests": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Counts what each hot-path action costs: React commits (render passes),
|
||||
* component renders, and JMAP requests by method. See playwright.config.ts.
|
||||
*
|
||||
* Each action runs a few times and the budget is the worst run, so one
|
||||
* unlucky interleaving does not decide the result, but a change that adds a
|
||||
* render pass or a request to every open does.
|
||||
*/
|
||||
|
||||
type Cost = { commits: number; renders: number; requests: string[] };
|
||||
type Budget = Record<string, { commits: number; renders: number; requests: number }>;
|
||||
|
||||
const UPDATE = process.env.PERF_GUARD_UPDATE === "1";
|
||||
// Renders vary a little with timing (a response landing before or after a
|
||||
// deferred write); commits and requests may not grow at all.
|
||||
const RENDER_SLACK = 1.1;
|
||||
|
||||
async function instrument(page: Page, requests: string[]) {
|
||||
await page.addInitScript(() => {
|
||||
// Never mark read: an Email/set per open is not the cost being measured.
|
||||
try {
|
||||
const raw = localStorage.getItem("settings-storage");
|
||||
const parsed = raw ? JSON.parse(raw) : { state: {}, version: 1 };
|
||||
parsed.state = { ...(parsed.state ?? {}), markAsReadDelay: -1 };
|
||||
localStorage.setItem("settings-storage", JSON.stringify(parsed));
|
||||
} catch { /* checked after login */ }
|
||||
const stats = { commits: 0, renders: 0 };
|
||||
(window as unknown as { __perfGuard: typeof stats }).__perfGuard = stats;
|
||||
// Function, class, forwardRef, memo and simple-memo components.
|
||||
const COUNTED = new Set([0, 1, 11, 14, 15]);
|
||||
type Fiber = { tag: number; flags: number; alternate: Fiber | null; child: Fiber | null; sibling: Fiber | null };
|
||||
const walk = (fiber: Fiber | null) => {
|
||||
for (let f = fiber; f; f = f.sibling) {
|
||||
const prev = f.alternate;
|
||||
// PerformedWork (1): the component's render function ran this commit.
|
||||
if (COUNTED.has(f.tag) && (prev === null || (f.flags & 1) === 1)) stats.renders++;
|
||||
// A subtree whose child list was not rebuilt bailed out entirely.
|
||||
if (f.child && (prev === null || f.child !== prev.child)) walk(f.child);
|
||||
}
|
||||
};
|
||||
const renderers = new Map<number, unknown>();
|
||||
(window as unknown as { __REACT_DEVTOOLS_GLOBAL_HOOK__: unknown }).__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
|
||||
supportsFiber: true,
|
||||
renderers,
|
||||
isDisabled: false,
|
||||
inject(renderer: unknown) { const id = renderers.size + 1; renderers.set(id, renderer); return id; },
|
||||
onCommitFiberRoot(_id: number, root: { current: Fiber }) { stats.commits++; try { walk(root.current.child); } catch { /* keep counting */ } },
|
||||
onCommitFiberUnmount() {},
|
||||
onPostCommitFiberRoot() {},
|
||||
onScheduleFiberRoot() {},
|
||||
checkDCE() {},
|
||||
};
|
||||
});
|
||||
page.on("request", (req) => {
|
||||
if (req.method() !== "POST" || !req.url().includes("/api/dev-jmap")) return;
|
||||
try {
|
||||
const calls = (JSON.parse(req.postData() || "{}").methodCalls || []) as [string][];
|
||||
if (calls.length) requests.push(calls.map((c) => c[0]).join("+"));
|
||||
} catch { /* not a JMAP call */ }
|
||||
});
|
||||
}
|
||||
|
||||
const LIST = '[data-tour="email-list"]';
|
||||
const VIEWER = '[data-tour="email-viewer"]';
|
||||
|
||||
test("hot paths stay within their render and request budget", async ({ page }, testInfo) => {
|
||||
// testDir is this directory (playwright.config.ts); works as CJS and ESM.
|
||||
const BUDGET_FILE = join(testInfo.project.testDir, "budget.json");
|
||||
const requests: string[] = [];
|
||||
await instrument(page, requests);
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: /^sign in$/i }).first().click();
|
||||
await page.waitForFunction((sel) => /#\d+/.test(document.querySelector(sel)?.textContent ?? ""), LIST, { timeout: 60_000 });
|
||||
expect(await page.evaluate(() => JSON.parse(localStorage.getItem("settings-storage") || "{}")?.state?.markAsReadDelay)).toBe(-1);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const settle = () => page.waitForTimeout(700);
|
||||
const measure = async (action: () => Promise<void>): Promise<Cost> => {
|
||||
await settle();
|
||||
requests.length = 0;
|
||||
const before = await page.evaluate(() => ({ ...(window as unknown as { __perfGuard: { commits: number; renders: number } }).__perfGuard }));
|
||||
await action();
|
||||
await settle();
|
||||
const after = await page.evaluate(() => ({ ...(window as unknown as { __perfGuard: { commits: number; renders: number } }).__perfGuard }));
|
||||
return { commits: after.commits - before.commits, renders: after.renders - before.renders, requests: requests.slice() };
|
||||
};
|
||||
|
||||
const open = async (index: number) => {
|
||||
const row = page.locator(`${LIST} [data-index="${index}"]`).first();
|
||||
const id = (await row.innerText()).match(/#\d+/)?.[0];
|
||||
await row.click();
|
||||
await page.waitForFunction(({ sel, id }) => {
|
||||
const v = document.querySelector(sel);
|
||||
const frame = v?.querySelector("iframe") as HTMLIFrameElement | null;
|
||||
const text = (v?.textContent ?? "") + (frame?.contentDocument?.body?.textContent ?? "");
|
||||
return !!id && text.includes(id) && /Synthetic seed message|Lorem/.test(text);
|
||||
}, { sel: VIEWER, id }, { timeout: 20_000 });
|
||||
};
|
||||
const search = async (query: string) => {
|
||||
const input = page.locator('input[type="search"]').first();
|
||||
await input.fill(query);
|
||||
await input.press("Enter");
|
||||
await page.locator('button[aria-label="Clear search"]').first().waitFor();
|
||||
await page.waitForFunction((sel) => !document.querySelector(`${sel} .animate-spin`), LIST);
|
||||
};
|
||||
const clearSearch = async () => {
|
||||
await page.locator('button[aria-label="Clear search"]').first().click();
|
||||
await page.waitForFunction((sel) => !document.querySelector('button[aria-label="Clear search"]') && !document.querySelector(`${sel} .animate-spin`), LIST);
|
||||
};
|
||||
const folder = async (name: string) => {
|
||||
await page.getByRole("button", { name, exact: false }).first().click();
|
||||
await page.waitForFunction((sel) => !document.querySelector(`${sel} .animate-spin`) && /#\d+/.test(document.querySelector(sel)?.textContent ?? ""), LIST);
|
||||
};
|
||||
|
||||
const runs: Record<string, Cost[]> = { open: [], search: [], "folder (first visit)": [], "folder (revisit)": [] };
|
||||
for (const i of [1, 2, 3]) runs.open.push(await measure(() => open(i)));
|
||||
for (const q of ["incident", "review"]) {
|
||||
runs.search.push(await measure(() => search(q)));
|
||||
await clearSearch();
|
||||
}
|
||||
runs["folder (first visit)"].push(await measure(() => folder("Sent")));
|
||||
runs["folder (first visit)"].push(await measure(() => folder("Drafts")));
|
||||
runs["folder (revisit)"].push(await measure(() => folder("Inbox")));
|
||||
runs["folder (revisit)"].push(await measure(() => folder("Sent")));
|
||||
|
||||
const worst: Budget = {};
|
||||
for (const [action, costs] of Object.entries(runs)) {
|
||||
worst[action] = {
|
||||
commits: Math.max(...costs.map((c) => c.commits)),
|
||||
renders: Math.max(...costs.map((c) => c.renders)),
|
||||
requests: Math.max(...costs.map((c) => c.requests.length)),
|
||||
};
|
||||
console.log(`${action}: ${JSON.stringify(worst[action])} requests=${JSON.stringify(costs.map((c) => c.requests))}`);
|
||||
}
|
||||
|
||||
if (UPDATE) {
|
||||
writeFileSync(BUDGET_FILE, JSON.stringify(worst, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
const budget = JSON.parse(readFileSync(BUDGET_FILE, "utf8")) as Budget;
|
||||
for (const [action, cost] of Object.entries(worst)) {
|
||||
const allowed = budget[action];
|
||||
expect(allowed, `no budget for "${action}"; run with PERF_GUARD_UPDATE=1`).toBeTruthy();
|
||||
expect(cost.commits, `${action}: render passes (commits)`).toBeLessThanOrEqual(allowed.commits);
|
||||
expect(cost.requests, `${action}: JMAP requests`).toBeLessThanOrEqual(allowed.requests);
|
||||
expect(cost.renders, `${action}: component renders`).toBeLessThanOrEqual(Math.ceil(allowed.renders * RENDER_SLACK));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Render and request budget for the mail view's hot paths (open a message,
|
||||
// search, switch folder). Runs against a production build made with
|
||||
// DEV_MOCK_JMAP=true (`DEV_MOCK_JMAP=true npx next build --webpack`), served
|
||||
// with the dev mock's benchmark-sized corpus. It counts React commits,
|
||||
// component renders and JMAP requests per action, which are stable where
|
||||
// timings are not, and fails when one goes over budget.json.
|
||||
//
|
||||
// DEV_MOCK_JMAP=true npx next build --webpack
|
||||
// npm run test:perf-guard # check
|
||||
// PERF_GUARD_UPDATE=1 npm run test:perf-guard # rewrite budget.json
|
||||
const port = Number(process.env.PERF_GUARD_PORT || 3150);
|
||||
// A server that is already running (e.g. a container built with
|
||||
// DEV_MOCK_JMAP=true and started with DEV_MOCK_JMAP_BULK=1000): nothing is
|
||||
// started then.
|
||||
const externalBaseUrl = (process.env.PERF_GUARD_BASE_URL ?? "").replace(/\/+$/, "");
|
||||
const adminDir = mkdtempSync(join(tmpdir(), "perf-guard-"));
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: /perf-guard\.spec\.ts$/,
|
||||
timeout: 180_000,
|
||||
// A count over budget is a result, not a flake.
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [["list"]] : "list",
|
||||
use: {
|
||||
baseURL: externalBaseUrl || `http://localhost:${port}`,
|
||||
locale: "en-US",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
|
||||
webServer: externalBaseUrl ? undefined : {
|
||||
command: `npx next start -p ${port}`,
|
||||
port,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
cwd: process.cwd(),
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
DEV_MOCK_JMAP: "true",
|
||||
DEV_MOCK_JMAP_BULK: "1000",
|
||||
JMAP_SERVER_URL: `http://localhost:${port}/api/dev-jmap`,
|
||||
SESSION_SECRET: "perf-guard-session-secret-0123456789abcdef",
|
||||
ADMIN_CONFIG_DIR: join(adminDir, "config"),
|
||||
ADMIN_STATE_DIR: join(adminDir, "state"),
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import { createConfig } from '@/lib/__tests__/fixtures/config';
|
||||
|
||||
// Signing in with "Remember me" stores the session in a cookie for the next
|
||||
// visit. Nothing on this visit needs it, so the login no longer waits for that
|
||||
// write (it verifies the password upstream once more); signing out waits for
|
||||
// it instead, so a write still running can't bring the cookie back.
|
||||
vi.mock('@/lib/jmap/client', () => {
|
||||
class JMAPClient {
|
||||
constructor(public serverUrl: string, public username: string, public password: string) {}
|
||||
static withBearer() { throw new Error('not used'); }
|
||||
async connect() {}
|
||||
getSessionUsername() { return this.username; }
|
||||
getUsername() { return this.username; }
|
||||
async getIdentities() { return [{ id: 'a', email: this.username, name: this.username, mayDelete: false }]; }
|
||||
getAuthHeader() { return 'Basic eA=='; }
|
||||
onConnectionChange() {}
|
||||
onRateLimit() {}
|
||||
getRateLimitRemainingMs() { return 0; }
|
||||
supportsContacts() { return false; }
|
||||
supportsPrincipals() { return false; }
|
||||
supportsVacationResponse() { return false; }
|
||||
supportsCalendars() { return false; }
|
||||
supportsSieve() { return false; }
|
||||
disconnect() {}
|
||||
getAccountId() { return 'acct'; }
|
||||
}
|
||||
class RateLimitError extends Error {}
|
||||
return { JMAPClient, RateLimitError };
|
||||
});
|
||||
vi.mock('@/lib/stalwart/principal', () => ({ fetchPrincipalDisplayName: async () => null }));
|
||||
|
||||
import * as nav from '@/lib/browser-navigation';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
const SERVER = 'https://mail.example.com';
|
||||
let releaseSessionWrite!: () => void;
|
||||
const calls: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null });
|
||||
useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null });
|
||||
vi.spyOn(nav, 'replaceWindowLocation').mockImplementation(() => {});
|
||||
const sessionWritten = new Promise<void>((resolve) => { releaseSessionWrite = resolve; });
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = init?.method ?? 'GET';
|
||||
calls.push(`${method} ${url.split('?')[0]}`);
|
||||
if (url === '/api/auth/verify') return new Response(JSON.stringify({ result: 'ok' }), { status: 200 });
|
||||
if (url.startsWith('/api/auth/session') && method === 'POST') {
|
||||
await sessionWritten;
|
||||
calls.push('session written');
|
||||
return new Response('{"ok":true}', { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
|
||||
return new Response('{}', { status: 200 });
|
||||
}));
|
||||
});
|
||||
|
||||
it('signs in without waiting for the remembered session to be stored', async () => {
|
||||
const ok = await useAuthStore.getState().login(SERVER, 'bob@example.com', 'pw', undefined, true);
|
||||
expect(ok).toBe(true);
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
expect(calls).toContain('POST /api/auth/session');
|
||||
expect(calls).not.toContain('session written');
|
||||
releaseSessionWrite();
|
||||
});
|
||||
|
||||
it('signs out only after a session write still running has finished', async () => {
|
||||
await useAuthStore.getState().login(SERVER, 'bob@example.com', 'pw', undefined, true);
|
||||
const signedOut = useAuthStore.getState().logout();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(calls).not.toContain('DELETE /api/auth/session');
|
||||
releaseSessionWrite();
|
||||
await signedOut;
|
||||
const written = calls.indexOf('session written');
|
||||
expect(written).toBeGreaterThan(-1);
|
||||
expect(calls.indexOf('DELETE /api/auth/session')).toBeGreaterThan(written);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { saveRedirectAfterLogin } from '../auth-store';
|
||||
|
||||
// Where login sends the user back to. The app's home is where a login lands
|
||||
// anyway; saving it made the login page navigate a second time, to /en/en
|
||||
// (the locale-aware router prefixes the locale again), racing the first.
|
||||
|
||||
describe('saveRedirectAfterLogin', () => {
|
||||
beforeEach(() => sessionStorage.clear());
|
||||
|
||||
it('saves nothing on the home page, and drops an older target', () => {
|
||||
sessionStorage.setItem('redirect_after_login', '/en/settings');
|
||||
window.history.pushState({}, '', '/en');
|
||||
saveRedirectAfterLogin();
|
||||
expect(sessionStorage.getItem('redirect_after_login')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a deep link, query and hash included', () => {
|
||||
window.history.pushState({}, '', '/en/calendar?view=day#e1');
|
||||
saveRedirectAfterLogin();
|
||||
expect(sessionStorage.getItem('redirect_after_login')).toBe('/en/calendar?view=day#e1');
|
||||
});
|
||||
|
||||
it('keeps the home page with a query, which carries a deep link', () => {
|
||||
window.history.pushState({}, '', '/en?email=abc');
|
||||
saveRedirectAfterLogin();
|
||||
expect(sessionStorage.getItem('redirect_after_login')).toBe('/en?email=abc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useContactStore } from '../contact-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
// Compose autocomplete learns recent recipients from the Sent folder. The
|
||||
// mail view asks again whenever the folder list changes, which it does
|
||||
// several times while signing in; the folder is read once.
|
||||
|
||||
describe('loadRecentRecipients', () => {
|
||||
beforeEach(() => {
|
||||
useContactStore.setState({ recentRecipients: [], recentRecipientsLoaded: false, sentMailboxId: null } as never);
|
||||
});
|
||||
|
||||
it('reads the Sent folder once while a read is in flight, and keeps what it found', async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
const sent = { id: 's1', to: [{ name: 'Bob', email: 'bob@example.com' }], cc: [] } as unknown as Email;
|
||||
const client = {
|
||||
getEmails: vi.fn(async () => { await gate; return { emails: [sent], hasMore: false, total: 1 }; }),
|
||||
} as unknown as IJMAPClient;
|
||||
|
||||
const first = useContactStore.getState().loadRecentRecipients(client, 'sent');
|
||||
const second = useContactStore.getState().loadRecentRecipients(client, 'sent');
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
await useContactStore.getState().loadRecentRecipients(client, 'sent');
|
||||
|
||||
expect(client.getEmails).toHaveBeenCalledTimes(1);
|
||||
expect(useContactStore.getState().recentRecipients).toEqual([{ name: 'Bob', email: 'bob@example.com' }]);
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ const folder = (id: string, role: string | null) =>
|
||||
myRights: {},
|
||||
}) as unknown as Mailbox;
|
||||
|
||||
type Page = { emails: Email[]; hasMore: boolean; total: number; state?: string };
|
||||
type Page = { emails: Email[]; hasMore: boolean; total: number; state?: string; threadCounts?: Record<string, number> };
|
||||
|
||||
function makeClient(pages: Record<string, () => Promise<Page>>) {
|
||||
const getEmails = vi.fn((mailboxId: string) => pages[mailboxId]());
|
||||
@@ -173,3 +173,83 @@ describe('remembered folder lists', () => {
|
||||
expect(ids()).toEqual(['w']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remembered folder lists with Email/changes', () => {
|
||||
beforeEach(() => {
|
||||
forgetFolderLists();
|
||||
useSettingsStore.setState({ emailsPerPage: 50 });
|
||||
useEmailStore.setState({
|
||||
selectedMailbox: 'inbox',
|
||||
mailboxes: [folder('inbox', 'inbox'), folder('work', null)],
|
||||
accountMailboxes: {},
|
||||
viewingAccountId: null,
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
hasMoreEmails: false,
|
||||
emailListSync: null,
|
||||
isUnifiedView: false,
|
||||
isScheduledView: false,
|
||||
selectedKeyword: null,
|
||||
searchQuery: '',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
function clientWithChanges(changes: { created?: string[]; updated?: string[]; destroyed?: string[] }) {
|
||||
const { client, getEmails } = makeClient({
|
||||
inbox: async () => ({ emails: [makeEmail('a'), makeEmail('b')], hasMore: false, total: 2, state: 's1', threadCounts: { 't-a': 3, 't-b': 1 } } as Page),
|
||||
work: async () => ({ emails: [makeEmail('w')], hasMore: false, total: 1, state: 's1' }),
|
||||
});
|
||||
const getEmailChanges = vi.fn(async () => ({
|
||||
oldState: 's1', newState: 's2', hasMoreChanges: false,
|
||||
created: changes.created ?? [], updated: changes.updated ?? [], destroyed: changes.destroyed ?? [],
|
||||
}));
|
||||
const getSomeEmails = vi.fn(async (wanted: string[]) => wanted.map((id) => makeEmail(id, { keywords: { $seen: true } })));
|
||||
Object.assign(client, { getEmailChanges, getSomeEmails });
|
||||
return { client, getEmails, getEmailChanges };
|
||||
}
|
||||
|
||||
it('asks only what changed on a revisit and keeps the rows when nothing did', async () => {
|
||||
const { client, getEmails, getEmailChanges } = clientWithChanges({});
|
||||
await open(client, 'inbox');
|
||||
await open(client, 'work');
|
||||
getEmails.mockClear();
|
||||
|
||||
const loading = open(client, 'inbox');
|
||||
const painted = useEmailStore.getState().emails;
|
||||
await loading;
|
||||
|
||||
expect(getEmailChanges).toHaveBeenCalledWith('s1', undefined, expect.any(Number));
|
||||
expect(getEmails).not.toHaveBeenCalled();
|
||||
// The very same list: nothing for the rows to render again.
|
||||
expect(useEmailStore.getState().emails).toBe(painted);
|
||||
expect(useEmailStore.getState().emailListSync?.state).toBe('s2');
|
||||
// The conversation sizes come back with the page; a switch cleared them.
|
||||
expect(useEmailStore.getState().threadEmailCounts.get('t-a')).toBe(3);
|
||||
});
|
||||
|
||||
it('patches a row that changed elsewhere without re-reading the page', async () => {
|
||||
const { client, getEmails } = clientWithChanges({ updated: ['a'] });
|
||||
await open(client, 'inbox');
|
||||
await open(client, 'work');
|
||||
getEmails.mockClear();
|
||||
|
||||
await open(client, 'inbox');
|
||||
|
||||
expect(getEmails).not.toHaveBeenCalled();
|
||||
expect(ids()).toEqual(['a', 'b']);
|
||||
expect(useEmailStore.getState().emails[0].keywords).toEqual({ $seen: true });
|
||||
});
|
||||
|
||||
it('re-reads the page when mail arrived in the folder', async () => {
|
||||
const { client, getEmails } = clientWithChanges({ created: ['new'] });
|
||||
(client.getSomeEmails as ReturnType<typeof vi.fn>).mockImplementation(async (wanted: string[]) => wanted.map((id) => makeEmail(id)));
|
||||
await open(client, 'inbox');
|
||||
await open(client, 'work');
|
||||
getEmails.mockClear();
|
||||
|
||||
await open(client, 'inbox');
|
||||
expect(getEmails).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { pendingInitialData, useEmailStore } from '../email-store';
|
||||
import { useSettingsStore } from '../settings-store';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// login() starts loading the mail data (prefetchInitialData). The mail view's
|
||||
// bootstrap asks pendingInitialData whether that load is running or has just
|
||||
// finished, and joins or skips it instead of sending everything again.
|
||||
|
||||
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0, sortOrder: 0, myRights: {} } as unknown as Mailbox;
|
||||
|
||||
function makeClient() {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
const client = {
|
||||
getAllMailboxes: vi.fn(async () => { await gate; return [inbox]; }),
|
||||
getQuota: vi.fn(async () => null),
|
||||
getEmails: vi.fn(async () => ({ emails: [], hasMore: false, total: 0, state: 's1' })),
|
||||
getTagCounts: vi.fn(async () => ({})),
|
||||
getAccountId: () => 'acc',
|
||||
} as unknown as IJMAPClient;
|
||||
return { client, release };
|
||||
}
|
||||
|
||||
describe('pendingInitialData', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailsPerPage: 50 });
|
||||
useEmailStore.setState({ mailboxes: [], emails: [], selectedMailbox: 'inbox', accountMailboxes: {} });
|
||||
});
|
||||
|
||||
it('hands out the running load, then reports it done once', async () => {
|
||||
const { client, release } = makeClient();
|
||||
expect(pendingInitialData(client)).toBeUndefined();
|
||||
|
||||
const loading = useEmailStore.getState().prefetchInitialData(client);
|
||||
expect(pendingInitialData(client)).toBeInstanceOf(Promise);
|
||||
|
||||
release();
|
||||
await loading;
|
||||
expect(pendingInitialData(client)).toBe('done');
|
||||
// Only the bootstrap right after the login may skip its fetch.
|
||||
expect(pendingInitialData(client)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import { emailHooks } from '@/lib/plugin-hooks';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
// Opening a message shows its list row at once (beginOpenEmail) and swaps in
|
||||
// the fetched message when it lands (finishOpenEmail). Plugins hear the open
|
||||
// once, with the full message, and the close of whatever was open before.
|
||||
|
||||
const row = (id: string): Email => ({
|
||||
id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: {}, subject: `Subject ${id}`,
|
||||
receivedAt: '2026-10-01T00:00:00Z', from: [], to: [], preview: 'preview', size: 1, hasAttachment: false,
|
||||
} as Email);
|
||||
const full = (id: string): Email => ({
|
||||
...row(id),
|
||||
textBody: [{ partId: '1', type: 'text/plain' }],
|
||||
bodyValues: { 1: { value: 'body text' } },
|
||||
} as unknown as Email);
|
||||
|
||||
describe('opening a message from the list', () => {
|
||||
const opened: string[] = [];
|
||||
const closed: string[] = [];
|
||||
let offOpen: () => void;
|
||||
let offClose: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
opened.length = 0;
|
||||
closed.length = 0;
|
||||
const openReg = emailHooks.onEmailOpen.register('test', ((view: { id: string; text?: string }) => { opened.push(`${view.id}:${view.text ?? ''}`); }) as never);
|
||||
const closeReg = emailHooks.onEmailClose.register('test', ((e: { emailId: string }) => { closed.push(e.emailId); }) as never);
|
||||
offOpen = () => openReg.dispose();
|
||||
offClose = () => closeReg.dispose();
|
||||
useEmailStore.setState({ selectedEmail: full('a'), isLoadingEmail: false });
|
||||
});
|
||||
afterEach(() => { offOpen(); offClose(); });
|
||||
|
||||
it('shows the row and its loading state in one write, then the fetched message', () => {
|
||||
// Every state the subscribers see: never the row without its loading
|
||||
// state (that would be a render with an empty body), never two writes.
|
||||
const seen: string[] = [];
|
||||
const unsubscribe = useEmailStore.subscribe((state, prev) => {
|
||||
if (state.selectedEmail !== prev.selectedEmail || state.isLoadingEmail !== prev.isLoadingEmail) {
|
||||
seen.push(`${state.selectedEmail?.id}:${state.isLoadingEmail}`);
|
||||
}
|
||||
});
|
||||
useEmailStore.getState().beginOpenEmail(row('b'));
|
||||
unsubscribe();
|
||||
|
||||
expect(seen).toEqual(['b:true']);
|
||||
expect(useEmailStore.getState()).toMatchObject({ selectedEmail: { id: 'b' }, isLoadingEmail: true });
|
||||
expect(closed).toEqual(['a']);
|
||||
// No open for the row: it has no body, headers or attachments yet.
|
||||
expect(opened).toEqual([]);
|
||||
|
||||
useEmailStore.getState().finishOpenEmail(full('b'));
|
||||
expect(useEmailStore.getState()).toMatchObject({ selectedEmail: { id: 'b', bodyValues: { 1: { value: 'body text' } } }, isLoadingEmail: false });
|
||||
expect(opened).toEqual(['b:body text']);
|
||||
expect(closed).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('reopens what was open before when the fetch fails', () => {
|
||||
const previous = useEmailStore.getState().selectedEmail;
|
||||
useEmailStore.getState().beginOpenEmail(row('b'));
|
||||
useEmailStore.getState().abandonOpenEmail(previous);
|
||||
|
||||
expect(useEmailStore.getState()).toMatchObject({ selectedEmail: previous, isLoadingEmail: false });
|
||||
expect(opened).toEqual(['a:body text']);
|
||||
});
|
||||
|
||||
it('announces close and open when the message comes without a row shown first', () => {
|
||||
useEmailStore.getState().finishOpenEmail(full('c'));
|
||||
expect(closed).toEqual(['a']);
|
||||
expect(opened).toEqual(['c:body text']);
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ describe('onSearch', () => {
|
||||
await useEmailStore.getState().searchEmails(client, 'invoice');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ query: 'invoice', filters: DEFAULT_SEARCH_FILTERS });
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('invoice -label:ads', 'inbox', undefined, 50, 0);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('invoice -label:ads', 'inbox', undefined, 50, 0, { deferSnippets: true });
|
||||
expect(useEmailStore.getState().searchQuery).toBe('invoice');
|
||||
});
|
||||
|
||||
|
||||
@@ -110,8 +110,8 @@ describe('"All folders" search across the own and group accounts (#1082)', () =>
|
||||
expect(state.hasMoreEmails).toBe(false);
|
||||
// One folder-less query per account.
|
||||
expect(client.searchEmails).toHaveBeenCalledTimes(2);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0, { deferSnippets: true });
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0, { deferSnippets: true });
|
||||
});
|
||||
|
||||
it('merges own and group hits newest first and stamps each with its source', async () => {
|
||||
@@ -151,7 +151,7 @@ describe('"All folders" search across the own and group accounts (#1082)', () =>
|
||||
await useEmailStore.getState().searchEmails(client, 'acesso');
|
||||
|
||||
expect(client.searchEmails).toHaveBeenCalledTimes(1);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'inbox', 'group', 50, 0);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'inbox', 'group', 50, 0, { deferSnippets: true });
|
||||
});
|
||||
|
||||
it('makes a single own-account query for a login without group folders', async () => {
|
||||
@@ -160,7 +160,7 @@ describe('"All folders" search across the own and group accounts (#1082)', () =>
|
||||
await useEmailStore.getState().searchEmails(client, 'fatura');
|
||||
|
||||
expect(client.searchEmails).toHaveBeenCalledTimes(1);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('fatura', undefined, undefined, 50, 0);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('fatura', undefined, undefined, 50, 0, { deferSnippets: true });
|
||||
expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['own-1', 'own-2']);
|
||||
});
|
||||
|
||||
@@ -281,8 +281,8 @@ describe('Spam and Trash in the folder-less search scopes', () => {
|
||||
await useEmailStore.getState().searchEmails(client, 'acesso');
|
||||
|
||||
expect(client.advancedSearchEmails).not.toHaveBeenCalled();
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0, { deferSnippets: true });
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0, { deferSnippets: true });
|
||||
|
||||
client.searchEmails.mockClear();
|
||||
useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'sender@example.com' } });
|
||||
@@ -296,7 +296,7 @@ describe('Spam and Trash in the folder-less search scopes', () => {
|
||||
await useEmailStore.getState().searchEmails(client, 'fatura');
|
||||
|
||||
expect(client.searchEmails).toHaveBeenCalledTimes(1);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0, { deferSnippets: true });
|
||||
});
|
||||
|
||||
it('goes back to the default scope when the filters are cleared', () => {
|
||||
@@ -311,7 +311,7 @@ describe('Spam and Trash in the folder-less search scopes', () => {
|
||||
|
||||
await useEmailStore.getState().searchEmails(client, 'fatura');
|
||||
expect(client.searchEmails).toHaveBeenCalledTimes(1);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0, { deferSnippets: true });
|
||||
|
||||
useEmailStore.getState().selectMailbox(ownJunk.id);
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('junk');
|
||||
@@ -326,7 +326,7 @@ describe('Spam and Trash in the folder-less search scopes', () => {
|
||||
await useEmailStore.getState().searchEmails(client, 'acesso');
|
||||
|
||||
expect(client.searchEmails).toHaveBeenCalledTimes(1);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'trash', 'group', 50, 0);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'trash', 'group', 50, 0, { deferSnippets: true });
|
||||
});
|
||||
|
||||
it('keeps a scope the user picked when entering or leaving Spam and Trash', () => {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient, SearchEmailsResult } from '@/lib/jmap/client-interface';
|
||||
|
||||
// Starting a search keeps the rows on screen (dimmed by the loading state)
|
||||
// instead of blanking the list, and writes that loading state only after the
|
||||
// request has left, so the click's render pass does not hold the request back.
|
||||
|
||||
const row = (id: string): Email => ({
|
||||
id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: {}, subject: id,
|
||||
receivedAt: '2026-10-01T00:00:00Z', from: [], to: [], preview: '', size: 1, hasAttachment: false,
|
||||
} as Email);
|
||||
|
||||
const nextTask = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe('starting a search', () => {
|
||||
let answer: (result: SearchEmailsResult) => void;
|
||||
let client: IJMAPClient;
|
||||
const folderRows = [row('old-1'), row('old-2')];
|
||||
|
||||
beforeEach(() => {
|
||||
client = {
|
||||
searchEmails: vi.fn(() => new Promise<SearchEmailsResult>((resolve) => { answer = resolve; })),
|
||||
advancedSearchEmails: vi.fn(),
|
||||
getSomeEmails: vi.fn().mockResolvedValue([]),
|
||||
getAccountId: () => 'me',
|
||||
} as unknown as IJMAPClient;
|
||||
useAuthStore.setState({
|
||||
activeAccountId: 'account-a',
|
||||
getClientForAccount: (id: string) => (id === 'account-a' ? client : undefined) as never,
|
||||
} as never);
|
||||
useSettingsStore.setState({ emailsPerPage: 50 } as never);
|
||||
useEmailStore.setState({
|
||||
isUnifiedView: false, unifiedRole: null, crossView: null, viewingAccountId: null, selectedKeyword: null,
|
||||
selectedMailbox: 'inbox', searchMailboxId: 'inbox',
|
||||
searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchAbortController: null,
|
||||
emails: folderRows, isLoading: false,
|
||||
mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 2, unreadEmails: 0 } as Mailbox],
|
||||
accountMailboxes: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('sends the request before anything the list renders from changes', async () => {
|
||||
const done = useEmailStore.getState().searchEmails(client, 'invoice');
|
||||
|
||||
expect(client.searchEmails).toHaveBeenCalledTimes(1);
|
||||
expect(useEmailStore.getState()).toMatchObject({ emails: folderRows, isLoading: false, searchQuery: '' });
|
||||
|
||||
await nextTask();
|
||||
// Loading now, over the old rows rather than an empty list.
|
||||
expect(useEmailStore.getState()).toMatchObject({ emails: folderRows, isLoading: true, searchQuery: 'invoice' });
|
||||
|
||||
answer({ emails: [row('hit')], hasMore: false, total: 1 });
|
||||
await done;
|
||||
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['hit']);
|
||||
expect(useEmailStore.getState()).toMatchObject({ isLoading: false, searchQuery: 'invoice' });
|
||||
});
|
||||
|
||||
it('records the query when the answer lands before the deferred loading write', async () => {
|
||||
const done = useEmailStore.getState().searchEmails(client, 'invoice');
|
||||
answer({ emails: [row('hit')], hasMore: false, total: 1 });
|
||||
await done;
|
||||
|
||||
expect(useEmailStore.getState()).toMatchObject({ searchQuery: 'invoice', isLoading: false });
|
||||
await nextTask();
|
||||
// The late loading write sees a finished search and stays out.
|
||||
expect(useEmailStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -80,8 +80,8 @@ describe('search folder scope (#788)', () => {
|
||||
// No mailbox id -> the JMAP client omits the inMailbox constraint; the
|
||||
// shared folder's owner is asked as well (#1082).
|
||||
expect(client.searchEmails).toHaveBeenCalledTimes(2);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('invoice', undefined, undefined, 50, 0);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('invoice', undefined, 'owner-x', 50, 0);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('invoice', undefined, undefined, 50, 0, { deferSnippets: true });
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('invoice', undefined, 'owner-x', 50, 0, { deferSnippets: true });
|
||||
});
|
||||
|
||||
it('advanced search from within a folder does not add an inMailbox condition', async () => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { SearchSnippetResult } from '@/lib/search-snippet';
|
||||
|
||||
// An interactive search lists its hits as soon as Email/query + Email/get
|
||||
// answer; the SearchSnippet/get highlighting follows in a request of its own
|
||||
// and is applied to the rows already on screen.
|
||||
|
||||
const hit = (id: string): Email => ({
|
||||
id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: {}, subject: `Subject ${id}`,
|
||||
receivedAt: '2026-10-01T00:00:00Z', from: [], to: [], preview: '', size: 1, hasAttachment: false,
|
||||
} as Email);
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => { resolve = r; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe('interactive search with deferred snippets', () => {
|
||||
let client: IJMAPClient;
|
||||
let snippets: ReturnType<typeof deferred<SearchSnippetResult[] | undefined>>;
|
||||
|
||||
beforeEach(() => {
|
||||
snippets = deferred();
|
||||
client = {
|
||||
searchEmails: vi.fn(async () => ({ emails: [hit('e1'), hit('e2')], hasMore: false, total: 2, snippets: snippets.promise })),
|
||||
advancedSearchEmails: vi.fn(),
|
||||
getSomeEmails: vi.fn().mockResolvedValue([]),
|
||||
getAccountId: () => 'me',
|
||||
} as unknown as IJMAPClient;
|
||||
useAuthStore.setState({
|
||||
activeAccountId: 'account-a',
|
||||
getClientForAccount: (id: string) => (id === 'account-a' ? client : undefined) as never,
|
||||
} as never);
|
||||
useSettingsStore.setState({ emailsPerPage: 50 } as never);
|
||||
useEmailStore.setState({
|
||||
isUnifiedView: false, unifiedRole: null, crossView: null, viewingAccountId: null, selectedKeyword: null,
|
||||
selectedMailbox: 'inbox',
|
||||
// Scoped to one folder: a single searchEmails call on the client.
|
||||
searchMailboxId: 'inbox',
|
||||
searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchAbortController: null, emails: [],
|
||||
mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 2, unreadEmails: 0 } as Mailbox],
|
||||
accountMailboxes: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('lists the results before the snippets arrive and highlights them after', async () => {
|
||||
await useEmailStore.getState().searchEmails(client, 'subject');
|
||||
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('subject', 'inbox', undefined, 50, 0, { deferSnippets: true });
|
||||
const listed = useEmailStore.getState().emails;
|
||||
expect(listed.map((e) => e.id)).toEqual(['e1', 'e2']);
|
||||
expect(listed[0].searchSnippet).toBeUndefined();
|
||||
|
||||
snippets.resolve([{ emailId: 'e2', subject: '<mark>Subject</mark> e2', preview: null }]);
|
||||
await vi.waitFor(() => expect(useEmailStore.getState().emails[1].searchSnippet).toBeDefined());
|
||||
const highlighted = useEmailStore.getState().emails;
|
||||
expect(highlighted[1].searchSnippet).toEqual({ subject: '<mark>Subject</mark> e2', preview: null });
|
||||
// The row without a snippet keeps its object, so it does not re-render.
|
||||
expect(highlighted[0]).toBe(listed[0]);
|
||||
});
|
||||
|
||||
it('drops the snippets when the search was left before they arrived', async () => {
|
||||
await useEmailStore.getState().searchEmails(client, 'subject');
|
||||
useEmailStore.setState({ searchQuery: '' });
|
||||
|
||||
snippets.resolve([{ emailId: 'e1', subject: '<mark>Subject</mark> e1', preview: null }]);
|
||||
await snippets.promise;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(useEmailStore.getState().emails.every((e) => e.searchSnippet === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// A folder page that brings its thread sizes along (Thread/get chained onto
|
||||
// the list request) is stored with them, and only a page without them sends
|
||||
// the separate Thread/get.
|
||||
|
||||
const row = (id: string, threadId: string): Email => ({
|
||||
id, threadId, mailboxIds: { inbox: true }, keywords: {}, subject: id,
|
||||
receivedAt: '2026-10-01T00:00:00Z', from: [], to: [], preview: '', size: 1, hasAttachment: false,
|
||||
} as Email);
|
||||
|
||||
function makeClient(threadCounts?: Record<string, number>) {
|
||||
return {
|
||||
getEmails: vi.fn(async () => ({ emails: [row('e1', 't1'), row('e2', 't2')], hasMore: false, total: 2, state: 's1', threadCounts })),
|
||||
getThreads: vi.fn(async () => [{ id: 't1', emailIds: ['e1', 'a'] }, { id: 't2', emailIds: ['e2'] }]),
|
||||
getAccountId: () => 'me',
|
||||
} as unknown as IJMAPClient;
|
||||
}
|
||||
|
||||
describe('thread sizes with the folder page', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailsPerPage: 50 } as never);
|
||||
useEmailStore.setState({
|
||||
isUnifiedView: false, unifiedRole: null, crossView: null, viewingAccountId: null, selectedKeyword: null,
|
||||
selectedMailbox: 'inbox', searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||||
emails: [], threadEmailCounts: new Map(),
|
||||
mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 2, unreadEmails: 0 } as Mailbox],
|
||||
accountMailboxes: {},
|
||||
});
|
||||
});
|
||||
|
||||
function connect(client: IJMAPClient) {
|
||||
useAuthStore.setState({
|
||||
activeAccountId: 'account-a', client,
|
||||
getClientForAccount: (id: string) => (id === 'account-a' ? client : undefined) as never,
|
||||
} as never);
|
||||
}
|
||||
|
||||
it('stores the counts the page brought and sends no Thread/get of its own', async () => {
|
||||
const client = makeClient({ t1: 4, t2: 1 });
|
||||
connect(client);
|
||||
|
||||
await useEmailStore.getState().fetchEmails(client, 'inbox');
|
||||
|
||||
expect(Object.fromEntries(useEmailStore.getState().threadEmailCounts)).toEqual({ t1: 4, t2: 1 });
|
||||
expect(client.getThreads).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks for the counts separately when the page came without them', async () => {
|
||||
const client = makeClient(undefined);
|
||||
connect(client);
|
||||
|
||||
await useEmailStore.getState().fetchEmails(client, 'inbox');
|
||||
await vi.waitFor(() => expect(client.getThreads).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(useEmailStore.getState().threadEmailCounts.get('t1')).toBe(2));
|
||||
});
|
||||
});
|
||||
+38
-7
@@ -511,12 +511,16 @@ async function persistSession(slot: number, session: RememberedSession): Promise
|
||||
saveLiteBasicSession(slot, session);
|
||||
return;
|
||||
}
|
||||
// A slot being signed out must not get its cookie back; sign-out waits for
|
||||
// a write still running (login doesn't wait for it, see there).
|
||||
if (closingSlots.has(slot)) return;
|
||||
try {
|
||||
const res = await apiFetch(`/api/auth/session?slot=${slot}`, {
|
||||
const res = await trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/session?slot=${slot}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...session, slot }),
|
||||
});
|
||||
signal,
|
||||
}));
|
||||
if (!res.ok) debug.error('Failed to store session: server returned', res.status);
|
||||
} catch (err) {
|
||||
debug.error('Failed to store session:', err);
|
||||
@@ -852,6 +856,15 @@ export function saveRedirectAfterLogin(): void {
|
||||
const loginPath = getLocaleLoginPath();
|
||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
|
||||
// The app's home is where a login lands anyway. Saving it only made the
|
||||
// login page navigate a second time, through the locale-aware router,
|
||||
// which prefixes the locale again (/en/en) and races the first.
|
||||
const home = `${getPathPrefix()}/${getLocaleFromPath()}`;
|
||||
if (currentPath === home || currentPath === `${home}/`) {
|
||||
sessionStorage.removeItem('redirect_after_login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPath !== loginPath) {
|
||||
sessionStorage.setItem('redirect_after_login', currentPath);
|
||||
}
|
||||
@@ -860,6 +873,16 @@ export function saveRedirectAfterLogin(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the login page can be reached without reloading the app. Before a
|
||||
* session was usable on this page load there is no mail state to throw away,
|
||||
* so a client-side navigation does and the app isn't downloaded and started
|
||||
* a second time; after one, the reload is what clears it.
|
||||
*/
|
||||
export function canEnterLoginInPlace(): boolean {
|
||||
return !signedInSincePageLoad && !leavingForProviderLogout;
|
||||
}
|
||||
|
||||
export function redirectToLogin(): void {
|
||||
if (typeof window === 'undefined' || leavingForProviderLogout) return;
|
||||
navigateToLogin();
|
||||
@@ -1415,18 +1438,20 @@ export const useAuthStore = create<AuthState>()(
|
||||
// Run the remaining independent requests in parallel. The session
|
||||
// write and stalwart-context write are best-effort persistence; the
|
||||
// outer login still succeeds even if they log a warning. Errors are
|
||||
// caught locally so Promise.all doesn't reject on either.
|
||||
// caught locally so Promise.all doesn't reject on either. Only the
|
||||
// context is waited for: the server-side helpers the mail view
|
||||
// calls next need it, while the remembered session only matters
|
||||
// for the next visit (and sign-out waits for its write).
|
||||
// In Lite a Basic session is kept for the tab (sessionStorage) on a
|
||||
// server without token login, so a reload does not sign the user
|
||||
// out; the regular build only writes the cookie when the user asked
|
||||
// to be remembered.
|
||||
const sessionWrite: Promise<unknown> = ((IS_LITE ? tokenLoginUnavailable : rememberMe) && !upgradedToOAuth)
|
||||
? persistSession(cookieSlot, { serverUrl, username, password })
|
||||
: Promise.resolve();
|
||||
if ((IS_LITE ? tokenLoginUnavailable : rememberMe) && !upgradedToOAuth) {
|
||||
void persistSession(cookieSlot, { serverUrl, username, password });
|
||||
}
|
||||
|
||||
const [rawIdentities] = await Promise.all([
|
||||
identitiesPromise,
|
||||
sessionWrite,
|
||||
syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot),
|
||||
]);
|
||||
|
||||
@@ -3063,6 +3088,12 @@ useAuthStore.subscribe((state, prev) => {
|
||||
if (state.isAuthenticated && !prev.isAuthenticated) void usePolicyStore.getState().refreshIfPartial();
|
||||
});
|
||||
|
||||
// See canEnterLoginInPlace.
|
||||
let signedInSincePageLoad = useAuthStore.getState().isAuthenticated;
|
||||
useAuthStore.subscribe((state) => {
|
||||
if (state.isAuthenticated) signedInSincePageLoad = true;
|
||||
});
|
||||
|
||||
// Another tab signed everyone out: this one's cookies and account list are
|
||||
// gone too, so leave for the login page instead of showing stale mail.
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
+28
-19
@@ -413,6 +413,9 @@ interface ContactStore {
|
||||
searchRecipients: (client: IJMAPClient, query: string) => Promise<Array<{ name: string; email: string }>>;
|
||||
}
|
||||
|
||||
/** The Sent-folder read loadRecentRecipients has in flight, if any. */
|
||||
let recentRecipientsLoading: Promise<void> | null = null;
|
||||
|
||||
export const useContactStore = create<ContactStore>()(
|
||||
persist(
|
||||
(set, get) => {
|
||||
@@ -1239,27 +1242,33 @@ export const useContactStore = create<ContactStore>()(
|
||||
loadRecentRecipients: async (client, sentMailboxId) => {
|
||||
if (sentMailboxId) set({ sentMailboxId });
|
||||
if (get().recentRecipientsLoaded || !sentMailboxId) return;
|
||||
try {
|
||||
// Read the Sent folder and collect the people we've written to, so
|
||||
// compose autocomplete can suggest them (OWA-style). getEmails sorts
|
||||
// receivedAt desc, so keeping the first occurrence per address yields
|
||||
// the most recent one plus its display name.
|
||||
const { emails } = await client.getEmails(sentMailboxId, undefined, 300, 0);
|
||||
const byEmail = new Map<string, { name: string; email: string }>();
|
||||
for (const email of emails) {
|
||||
for (const r of [...(email.to || []), ...(email.cc || [])]) {
|
||||
if (!r.email) continue;
|
||||
const key = r.email.toLowerCase().trim();
|
||||
if (!key || byEmail.has(key)) continue;
|
||||
byEmail.set(key, { name: (r.name || '').trim(), email: r.email });
|
||||
// One read of the Sent folder at a time: callers retry whenever the
|
||||
// folder list changes, which it does several times while signing in.
|
||||
if (recentRecipientsLoading) return recentRecipientsLoading;
|
||||
recentRecipientsLoading = (async () => {
|
||||
try {
|
||||
// Read the Sent folder and collect the people we've written to, so
|
||||
// compose autocomplete can suggest them (OWA-style). getEmails sorts
|
||||
// receivedAt desc, so keeping the first occurrence per address yields
|
||||
// the most recent one plus its display name.
|
||||
const { emails } = await client.getEmails(sentMailboxId, undefined, 300, 0);
|
||||
const byEmail = new Map<string, { name: string; email: string }>();
|
||||
for (const email of emails) {
|
||||
for (const r of [...(email.to || []), ...(email.cc || [])]) {
|
||||
if (!r.email) continue;
|
||||
const key = r.email.toLowerCase().trim();
|
||||
if (!key || byEmail.has(key)) continue;
|
||||
byEmail.set(key, { name: (r.name || '').trim(), email: r.email });
|
||||
}
|
||||
}
|
||||
set({ recentRecipients: Array.from(byEmail.values()), recentRecipientsLoaded: true });
|
||||
debug.log('contacts', 'Loaded', byEmail.size, 'recent recipients from Sent');
|
||||
} catch (error) {
|
||||
debug.error('Failed to load recent recipients:', error);
|
||||
set({ recentRecipientsLoaded: true });
|
||||
}
|
||||
set({ recentRecipients: Array.from(byEmail.values()), recentRecipientsLoaded: true });
|
||||
debug.log('contacts', 'Loaded', byEmail.size, 'recent recipients from Sent');
|
||||
} catch (error) {
|
||||
debug.error('Failed to load recent recipients:', error);
|
||||
set({ recentRecipientsLoaded: true });
|
||||
}
|
||||
})().finally(() => { recentRecipientsLoading = null; });
|
||||
return recentRecipientsLoading;
|
||||
},
|
||||
|
||||
searchRecipients: async (client, query) => {
|
||||
|
||||
+191
-34
@@ -1,7 +1,9 @@
|
||||
import { create } from "zustand";
|
||||
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types";
|
||||
import type { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { IJMAPClient, SearchEmailsResult } from "@/lib/jmap/client-interface";
|
||||
import { withSearchSnippets, type DeferredSnippetBatch } from "@/lib/search-snippet";
|
||||
import { afterRequestDispatch } from "@/lib/after-request-dispatch";
|
||||
import { useSettingsStore, getMessageListOrderFor } from "@/stores/settings-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { orderKeywords, placeHeldRows, type HeldKeywords, type SortLevel } from "@/lib/message-list-order";
|
||||
@@ -17,7 +19,7 @@ import { defaultSearchScopeFor, isAllFoldersSearchScope, SEARCH_SCOPE_ALL_FOLDER
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { pathNamesMailFolder } from "@/lib/deep-links";
|
||||
import { currentStoreEpoch } from "@/lib/store-epoch";
|
||||
import { folderListKey, forgetFolderLists, rememberFolderList, rememberedFolderList } from "@/lib/folder-list-cache";
|
||||
import { folderListKey, forgetFolderLists, rememberFolderList, rememberedFolderList, type FolderList } from "@/lib/folder-list-cache";
|
||||
import { keywordPointer } from "@/lib/jmap/patch-pointer";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
|
||||
@@ -255,6 +257,17 @@ interface EmailStore {
|
||||
*/
|
||||
fetchAccountMailboxes: (client: IJMAPClient, accountId: string) => Promise<void>;
|
||||
selectEmail: (email: Email | null) => void;
|
||||
/**
|
||||
* Shows a list row in the viewer while its body is on its way: selects it
|
||||
* and marks the body loading in one store write, so the click renders once.
|
||||
* Plugins hear onEmailOpen only from finishOpenEmail, with the full message:
|
||||
* a row has no body, headers or attachments to show them.
|
||||
*/
|
||||
beginOpenEmail: (row: Email) => void;
|
||||
/** The fetched message replaces the row beginOpenEmail showed (one write). */
|
||||
finishOpenEmail: (email: Email) => void;
|
||||
/** The fetch failed: back to what was open before, without the loading state. */
|
||||
abandonOpenEmail: (previous: Email | null) => void;
|
||||
/** Holds a list row in place (see listHold); `rowKey` as from listRowKey. */
|
||||
holdListRow: (rowKey: string) => void;
|
||||
selectMailbox: (mailboxId: string) => void;
|
||||
@@ -651,7 +664,7 @@ function refillAfterKeywordChange(get: StoreGet, client: IJMAPClient, changed: I
|
||||
void s.refreshCurrentMailbox(client);
|
||||
}
|
||||
|
||||
async function applyEmailDeltaNow(client: IJMAPClient, newState: string, get: StoreGet, set: StoreSet): Promise<boolean> {
|
||||
async function applyEmailDeltaNow(client: IJMAPClient, newState: string | null, get: StoreGet, set: StoreSet): Promise<boolean> {
|
||||
const s = get();
|
||||
const sync = s.emailListSync;
|
||||
if (!sync) return false;
|
||||
@@ -675,8 +688,9 @@ async function applyEmailDeltaNow(client: IJMAPClient, newState: string, get: St
|
||||
const effectiveClient = resolveActionClient(client);
|
||||
if (!effectiveClient.getEmailChanges) return false;
|
||||
// The push carries the state we already hold (our own Email/set raised it,
|
||||
// or a duplicate notification): nothing to do, and no request either.
|
||||
if (sync.state === newState) return true;
|
||||
// or a duplicate notification): nothing to do, and no request either. A
|
||||
// null newState (a folder repainted from memory) only the server can judge.
|
||||
if (newState !== null && sync.state === newState) return true;
|
||||
|
||||
const accountArg = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
const delta = await effectiveClient.getEmailChanges(sync.state, accountArg, EMAIL_DELTA_MAX_CHANGES);
|
||||
@@ -723,6 +737,15 @@ async function applyEmailDeltaNow(client: IJMAPClient, newState: string, get: St
|
||||
annotateScheduledEmails(replacements, get().scheduledSubmissionByEmailId).map((e) => [e.id, e]),
|
||||
);
|
||||
|
||||
// Nothing on the page changed: advance the baseline and leave the list be,
|
||||
// rather than hand it a new array that renders the same rows again.
|
||||
if (removed.size === 0 && replacementById.size === 0) {
|
||||
set((state) => ({
|
||||
emailListSync: state.emailListSync?.mailboxId === sync.mailboxId ? { ...sync, state: delta.newState } : state.emailListSync,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Re-read the list: the requests above may have overlapped a user action.
|
||||
const current = get().emails;
|
||||
const next = current
|
||||
@@ -975,6 +998,57 @@ export function resolveEmailBlobContext(
|
||||
*/
|
||||
let shownFolderList: { key: string; mailboxId: string; pageSize: number } | null = null;
|
||||
|
||||
/**
|
||||
* The initial data load login() started for this client (prefetchInitialData):
|
||||
* the promise while it runs, 'done' (once) after it loaded the mailboxes and a page.
|
||||
* The mail view's own bootstrap joins or skips it rather than sending the
|
||||
* same Mailbox/get, folder page and tag counts a second time.
|
||||
*/
|
||||
export function pendingInitialData(client: IJMAPClient): Promise<void> | 'done' | undefined {
|
||||
const target = client as IJMAPClient & { __prefetchPromise?: Promise<void>; __initialDataLoaded?: boolean };
|
||||
if (target.__prefetchPromise) return target.__prefetchPromise;
|
||||
// Good for the one bootstrap that follows the login; any later one (an
|
||||
// account switch back to this client) refreshes as before.
|
||||
if (!target.__initialDataLoaded) return undefined;
|
||||
delete target.__initialDataLoaded;
|
||||
return 'done';
|
||||
}
|
||||
|
||||
/** What a folder's list is remembered as (see lib/folder-list-cache). */
|
||||
function folderListState(s: EmailStore): FolderList {
|
||||
return {
|
||||
emails: s.emails,
|
||||
totalEmails: s.totalEmails,
|
||||
hasMoreEmails: s.hasMoreEmails,
|
||||
sync: s.emailListSync,
|
||||
threadEmailCounts: s.threadEmailCounts,
|
||||
};
|
||||
}
|
||||
|
||||
/** The list row beginOpenEmail put in the viewer, until its message arrives. */
|
||||
let openingRow: Email | null = null;
|
||||
|
||||
/**
|
||||
* The per-thread message counts to store once a page arrived: the counts that
|
||||
* page brought along (Thread/get chained onto its query) under the keys the
|
||||
* list groups threads by, merged over `current`. Empty when it brought none,
|
||||
* so the caller knows to ask for them separately.
|
||||
*/
|
||||
function threadCountsPatch(
|
||||
emails: Email[],
|
||||
counts: Record<string, number> | undefined,
|
||||
current: Map<string, number>,
|
||||
): { threadEmailCounts: Map<string, number> } | Record<string, never> {
|
||||
if (!counts) return {};
|
||||
const merged = new Map(current);
|
||||
for (const email of emails) {
|
||||
if (!email.threadId) continue;
|
||||
const count = counts[email.threadId];
|
||||
if (count !== undefined) merged.set(threadKeyFor(email), count);
|
||||
}
|
||||
return { threadEmailCounts: merged };
|
||||
}
|
||||
|
||||
function captureEmailListView(): () => boolean {
|
||||
const view = useEmailStore.getState();
|
||||
const activeAccountId = useAuthStore.getState().activeAccountId;
|
||||
@@ -1469,6 +1543,34 @@ function isUnscopedSearchActive(
|
||||
* The fan-out options of a folder-less search scope: only "All folders"
|
||||
* searches Trash and Junk, the default scope ("") leaves them out.
|
||||
*/
|
||||
/** A single-account search result with its deferred snippets in fan-out shape. */
|
||||
function withSnippetBatches(
|
||||
result: SearchEmailsResult,
|
||||
): Omit<SearchEmailsResult, 'snippets'> & { snippets?: Promise<DeferredSnippetBatch[]> } {
|
||||
const { snippets, ...rest } = result;
|
||||
return snippets ? { ...rest, snippets: snippets.then((list) => (list ? [{ list }] : [])) } : rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights the results of an interactive search once its deferred
|
||||
* SearchSnippet/get lands (see SearchRequestOptions.deferSnippets). The list
|
||||
* is on screen by then, so this applies only while that same search still is:
|
||||
* matching ids would not do, since a folder opened in the meantime can hold
|
||||
* the same messages and must not show search excerpts.
|
||||
*/
|
||||
function highlightWhenSnippetsArrive(snippets: Promise<DeferredSnippetBatch[]> | undefined): void {
|
||||
if (!snippets) return;
|
||||
const { searchQuery, searchFilters, searchMailboxId } = useEmailStore.getState();
|
||||
const isSameView = captureEmailListView();
|
||||
void snippets.then((batches) => {
|
||||
const state = useEmailStore.getState();
|
||||
if (!isSameView() || state.isLoading) return;
|
||||
if (state.searchQuery !== searchQuery || state.searchFilters !== searchFilters || state.searchMailboxId !== searchMailboxId) return;
|
||||
const emails = withSearchSnippets(state.emails, batches);
|
||||
if (emails !== state.emails) useEmailStore.setState({ emails });
|
||||
});
|
||||
}
|
||||
|
||||
function acrossAccountsSearchOptions(searchMailboxId: string): AcrossAccountsSearchOptions {
|
||||
return { excludeTrashAndJunk: searchMailboxId !== SEARCH_SCOPE_ALL_FOLDERS };
|
||||
}
|
||||
@@ -2133,6 +2235,36 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
emailHooks.onEmailOpen.emitSync(emailToReadView(email));
|
||||
}
|
||||
},
|
||||
beginOpenEmail: (row) => {
|
||||
const prev = get().selectedEmail;
|
||||
openingRow = row;
|
||||
set({ selectedEmail: row, lastSelectedEmailKey: emailKeyFor(row), isLoadingEmail: true });
|
||||
if (prev && row.id !== prev.id && emailHooks.onEmailClose.size > 0) {
|
||||
emailHooks.onEmailClose.emitSync({ emailId: prev.id });
|
||||
}
|
||||
},
|
||||
finishOpenEmail: (email) => {
|
||||
const prev = get().selectedEmail;
|
||||
const announced = prev !== null && prev !== openingRow;
|
||||
openingRow = null;
|
||||
set({ selectedEmail: email, lastSelectedEmailKey: emailKeyFor(email), isLoadingEmail: false });
|
||||
if (announced && prev.id !== email.id && emailHooks.onEmailClose.size > 0) {
|
||||
emailHooks.onEmailClose.emitSync({ emailId: prev.id });
|
||||
}
|
||||
if ((!announced || prev.id !== email.id) && emailHooks.onEmailOpen.size > 0) {
|
||||
emailHooks.onEmailOpen.emitSync(emailToReadView(email));
|
||||
}
|
||||
},
|
||||
abandonOpenEmail: (previous) => {
|
||||
const showing = get().selectedEmail;
|
||||
const wasRow = showing !== null && showing === openingRow;
|
||||
openingRow = null;
|
||||
set({ selectedEmail: previous, isLoadingEmail: false });
|
||||
// beginOpenEmail announced the close of `previous`; it is open again.
|
||||
if (wasRow && previous && showing.id !== previous.id && emailHooks.onEmailOpen.size > 0) {
|
||||
emailHooks.onEmailOpen.emitSync(emailToReadView(previous));
|
||||
}
|
||||
},
|
||||
holdListRow: (rowKey) => {
|
||||
const { listHold, emails, isScheduledView } = get();
|
||||
if (listHold?.rowKey === rowKey) return;
|
||||
@@ -2350,7 +2482,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Coalesce overlapping callers (e.g. login() and a slow home-page useEffect
|
||||
// racing for the same fetch). The promise is stashed on the client so we
|
||||
// don't need a separate keyed map and stale entries can't outlive the client.
|
||||
const target = client as IJMAPClient & { __prefetchPromise?: Promise<void> };
|
||||
const target = client as IJMAPClient & { __prefetchPromise?: Promise<void>; __initialDataLoaded?: boolean };
|
||||
if (target.__prefetchPromise) return target.__prefetchPromise;
|
||||
target.__prefetchPromise = (async () => {
|
||||
try {
|
||||
@@ -2364,6 +2496,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
} else {
|
||||
await get().fetchEmails(client);
|
||||
}
|
||||
target.__initialDataLoaded = get().mailboxes.length > 0;
|
||||
// Tag counts can finish whenever; don't block the prefetch on them.
|
||||
void get().fetchTagCounts(client);
|
||||
} finally {
|
||||
@@ -2515,8 +2648,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
hasMoreEmails: remembered.hasMoreEmails,
|
||||
totalEmails: remembered.totalEmails,
|
||||
listOrder: order,
|
||||
// The remembered page has no state a push delta could start from.
|
||||
emailListSync: null,
|
||||
// The state the page was current at, if it was a plain folder list:
|
||||
// a push delta, and the catch-up below, start from it.
|
||||
emailListSync: remembered.sync?.mailboxId === targetMailboxId ? remembered.sync : null,
|
||||
threadEmailCounts: remembered.threadEmailCounts ?? new Map(),
|
||||
threadEmailsCache: new Map(),
|
||||
expandedThreadIds: new Set(),
|
||||
isLoadingThread: null,
|
||||
@@ -2527,6 +2662,19 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// whatever was showing before and must not be remembered under this key.
|
||||
shownFolderList = remembered ? { key: listKey, mailboxId: targetMailboxId, pageSize: emailsPerPage } : null;
|
||||
|
||||
// A remembered page with a baseline only needs what changed since
|
||||
// (Email/changes): usually nothing, and then the rows stay as they are
|
||||
// instead of being re-read and rendered again. Anything the delta
|
||||
// cannot settle falls through to the full page below.
|
||||
if (remembered && get().emailListSync && !categoryFilter) {
|
||||
const upToDate = await applyEmailDeltaNow(client, null, get, set);
|
||||
if (!isCurrentView()) return;
|
||||
if (upToDate) {
|
||||
rememberFolderList(listKey, folderListState(get()), emailsPerPage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await effectiveClient.getEmails(
|
||||
jmapMailboxId,
|
||||
accountId,
|
||||
@@ -2550,6 +2698,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
mailboxId: targetMailboxId,
|
||||
}
|
||||
: null;
|
||||
const threadCounts = threadCountsPatch(enrichedEmails, result.threadCounts, get().threadEmailCounts);
|
||||
set({
|
||||
emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId),
|
||||
hasMoreEmails: result.hasMore,
|
||||
@@ -2560,12 +2709,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
threadEmailsCache: new Map(),
|
||||
expandedThreadIds: new Set(),
|
||||
isLoadingThread: null,
|
||||
...threadCounts,
|
||||
isLoading: false
|
||||
});
|
||||
shownFolderList = { key: listKey, mailboxId: targetMailboxId, pageSize: emailsPerPage };
|
||||
rememberFolderList(listKey, get(), emailsPerPage);
|
||||
// Fetch full thread counts in the background (non-blocking)
|
||||
void get().fetchThreadEmailCounts(client);
|
||||
rememberFolderList(listKey, folderListState(get()), emailsPerPage);
|
||||
// Only a server that did not answer the chained Thread/get needs a
|
||||
// request of its own for the conversation sizes.
|
||||
if (!('threadEmailCounts' in threadCounts)) void get().fetchThreadEmailCounts(client);
|
||||
} catch (error) {
|
||||
if (!isCurrentView()) return;
|
||||
console.error('Failed to fetch emails:', error);
|
||||
@@ -2789,14 +2940,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
|
||||
const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails);
|
||||
if (!isCurrentView()) return;
|
||||
const threadCounts = threadCountsPatch(
|
||||
enrichedNewEmails, 'threadCounts' in result ? result.threadCounts : undefined, get().threadEmailCounts,
|
||||
);
|
||||
set({
|
||||
emails: [...currentEmails, ...enrichedNewEmails],
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
...threadCounts,
|
||||
isLoadingMore: false
|
||||
});
|
||||
// Fetch full thread counts for newly loaded threads in the background
|
||||
if (enrichedNewEmails.length > 0) {
|
||||
// Thread counts for the new rows, unless the page brought them along.
|
||||
if (enrichedNewEmails.length > 0 && !('threadEmailCounts' in threadCounts)) {
|
||||
void get().fetchThreadEmailCounts(client);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -3618,15 +3773,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
set({
|
||||
isLoading: true,
|
||||
error: null,
|
||||
searchQuery: typedQuery,
|
||||
emails: [],
|
||||
hasMoreEmails: false,
|
||||
totalEmails: 0,
|
||||
searchAbortController: controller,
|
||||
}); // Clear emails for loading state
|
||||
// The rows on screen stay, dimmed, until the results land, as on a folder
|
||||
// switch, instead of being torn down for a skeleton and rebuilt a round
|
||||
// trip later. What the mail view renders from is written only once the
|
||||
// request is on its way (see afterRequestDispatch); the controller is
|
||||
// recorded at once so a superseding search can still cancel this one.
|
||||
set({ searchAbortController: controller });
|
||||
afterRequestDispatch(() => {
|
||||
if (get().searchAbortController === controller) set({ isLoading: true, error: null, searchQuery: typedQuery });
|
||||
});
|
||||
try {
|
||||
// The store keeps what the user typed; plugins' changes apply to this run.
|
||||
// Without listeners the request goes out in this same tick, as before.
|
||||
@@ -3679,7 +3834,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// shared accounts' mail (#1082). The default scope leaves every
|
||||
// account's Trash and Junk out; "All folders" searches them too.
|
||||
const built = buildTagViewAccountClients(client);
|
||||
result = await searchAcrossAccounts(built, query, emailsPerPage, 0, acrossAccountsSearchOptions(searchMailboxId));
|
||||
result = await searchAcrossAccounts(built, query, emailsPerPage, 0, { ...acrossAccountsSearchOptions(searchMailboxId), deferSnippets: true });
|
||||
unifiedErrors = result.errors;
|
||||
|
||||
} else {
|
||||
@@ -3691,7 +3846,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Only pass accountId for shared mailboxes, not for primary account
|
||||
accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
|
||||
result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
|
||||
result = withSnippetBatches(await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0, { deferSnippets: true }));
|
||||
}
|
||||
|
||||
// Plugins may add messages to the results by id; the messages are
|
||||
@@ -3717,14 +3872,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
externalSearchResults: externals,
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
// Also here: a fast answer can land before the deferred loading write.
|
||||
searchQuery: typedQuery,
|
||||
isLoading: false,
|
||||
searchAbortController: null,
|
||||
...(unifiedErrors ? { unifiedErrors } : {})
|
||||
});
|
||||
highlightWhenSnippetsArrive('snippets' in result ? result.snippets : undefined);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to search emails",
|
||||
searchQuery: typedQuery,
|
||||
isLoading: false,
|
||||
emails: [],
|
||||
externalSearchResults: [],
|
||||
@@ -3744,13 +3903,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
set({
|
||||
isLoading: true,
|
||||
error: null,
|
||||
emails: [],
|
||||
hasMoreEmails: false,
|
||||
totalEmails: 0,
|
||||
searchAbortController: controller,
|
||||
// See searchEmails: the rows stay, the loading state follows the request.
|
||||
set({ searchAbortController: controller });
|
||||
afterRequestDispatch(() => {
|
||||
if (get().searchAbortController === controller) set({ isLoading: true, error: null });
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -3799,7 +3955,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const built = buildTagViewAccountClients(client);
|
||||
result = await advancedSearchAcrossAccounts(
|
||||
built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0,
|
||||
acrossAccountsSearchOptions(searchMailboxId),
|
||||
{ ...acrossAccountsSearchOptions(searchMailboxId), deferSnippets: true },
|
||||
);
|
||||
unifiedErrors = result.errors;
|
||||
|
||||
@@ -3809,7 +3965,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
|
||||
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
||||
result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0);
|
||||
result = withSnippetBatches(await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0, { deferSnippets: true }));
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
@@ -3841,6 +3997,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
searchAbortController: null,
|
||||
...(unifiedErrors ? { unifiedErrors } : {})
|
||||
});
|
||||
highlightWhenSnippetsArrive('snippets' in result ? result.snippets : undefined);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
set({
|
||||
@@ -6522,7 +6679,7 @@ useEmailStore.subscribe((state, prev) => {
|
||||
shownFolderList = null;
|
||||
return;
|
||||
}
|
||||
rememberFolderList(shownFolderList.key, state, shownFolderList.pageSize);
|
||||
rememberFolderList(shownFolderList.key, folderListState(state), shownFolderList.pageSize);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user