mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: show where a search matched in each result
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createFakeJmap } from '../../stores/__tests__/fake-jmap-server';
|
||||
|
||||
// A search in the unified views highlights its hits like the folder list
|
||||
// (webmail searchAcrossAccounts with deferSnippets, 2d67442a2): the page
|
||||
// comes back first, each account's SearchSnippet/get follows, and the
|
||||
// excerpts are keyed by account because ids repeat across them.
|
||||
|
||||
const server = vi.hoisted(() => ({ current: null as null | ReturnType<typeof import('../../stores/__tests__/fake-jmap-server').createFakeJmap> }));
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
isConnected: true,
|
||||
accountId: 'c',
|
||||
username: 'me',
|
||||
serverUrl: 'https://mail.example.com',
|
||||
currentSession: { apiUrl: 'https://mail.example.com/jmap/', accounts: {} },
|
||||
getSharedMailAccounts: () => [{ id: 'team', name: 'Team' }],
|
||||
request: vi.fn(async (calls: Array<[string, Record<string, unknown>, string]>) => {
|
||||
// The fake answers mail calls; folders are answered here.
|
||||
if (calls[0][0] === 'Mailbox/get') {
|
||||
const accountId = calls[0][1].accountId as string;
|
||||
return { methodResponses: [['Mailbox/get', { accountId, list: [{ id: `${accountId}-in`, role: 'inbox', name: 'Inbox' }] }, calls[0][2]]] };
|
||||
}
|
||||
return server.current!.request(calls as never);
|
||||
}),
|
||||
},
|
||||
rewriteSessionUrl: (u: string) => u,
|
||||
extractOrigin: (u: string) => u,
|
||||
parseRetryAfter: () => 0,
|
||||
REQUEST_TIMEOUT_MS: 30000,
|
||||
}));
|
||||
|
||||
import { fetchUnifiedInbox, resetUnifiedCache, unifiedSnippetStamp } from '../unified-inbox';
|
||||
import { withSearchSnippets } from '../../lib/search-snippet';
|
||||
import type { Email } from '../types';
|
||||
|
||||
// The live account's registry id: username@host.
|
||||
const ACCOUNT = 'me@mail.example.com';
|
||||
|
||||
function mail(id: string, folder: string, day: number, subject: string, preview: string): Email {
|
||||
return {
|
||||
id, threadId: `t-${id}`, size: 1, hasAttachment: false,
|
||||
mailboxIds: { [folder]: true }, keywords: {},
|
||||
receivedAt: `2026-09-${String(day).padStart(2, '0')}T10:00:00Z`,
|
||||
subject, preview,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetUnifiedCache();
|
||||
server.current = createFakeJmap({
|
||||
c: [mail('a', 'c-in', 20, 'Zephyr launch', 'own body'), mail('b', 'c-in', 19, 'Lunch', 'nothing')],
|
||||
team: [mail('a', 'team-in', 22, 'Team sync', 'the zephyr review')],
|
||||
});
|
||||
});
|
||||
|
||||
describe('unified search snippets', () => {
|
||||
it("hands back each account's excerpts for its own rows", async () => {
|
||||
const result = await fetchUnifiedInbox([ACCOUNT], 25, { includeGroup: true, query: 'zephyr' });
|
||||
expect(result.emails.map((e) => `${e.jmapAccountId}:${e.id}`)).toEqual(['team:a', 'c:a']);
|
||||
expect(result.snippets).toBeDefined();
|
||||
|
||||
const batches = await result.snippets!;
|
||||
const calls = server.current!.callsOf('SearchSnippet/get');
|
||||
expect(calls.map(([, args]) => [args.accountId, args.emailIds])).toEqual(
|
||||
expect.arrayContaining([['c', ['a']], ['team', ['a']]]),
|
||||
);
|
||||
const rows = withSearchSnippets(result.emails, batches, unifiedSnippetStamp);
|
||||
expect(rows.find((e) => e.jmapAccountId === 'team')?.searchSnippet)
|
||||
.toEqual({ subject: null, preview: 'the <mark>zephyr</mark> review' });
|
||||
expect(rows.find((e) => e.jmapAccountId === 'c')?.searchSnippet)
|
||||
.toEqual({ subject: '<mark>Zephyr</mark> launch', preview: null });
|
||||
});
|
||||
|
||||
it('asks for no excerpts without a search', async () => {
|
||||
const result = await fetchUnifiedInbox([ACCOUNT], 25, { includeGroup: true });
|
||||
expect(result.emails).toHaveLength(3);
|
||||
expect(result.snippets).toBeUndefined();
|
||||
expect(server.current!.callsOf('SearchSnippet/get')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('lists the page even when an account cannot excerpt', async () => {
|
||||
const release = server.current!.holdSnippets();
|
||||
const result = await fetchUnifiedInbox([ACCOUNT], 25, { includeGroup: true, query: 'zephyr' });
|
||||
expect(result.emails).toHaveLength(2);
|
||||
server.current!.failing.set('team', 'forbidden');
|
||||
release();
|
||||
const batches = await result.snippets!;
|
||||
expect(batches.map((b) => b.accountId)).toEqual([`${ACCOUNT}|c`]);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Attachment, Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types';
|
||||
import { textFilter } from '../lib/search-utils';
|
||||
import { snippetFilterFor, type SearchSnippetResult } from '../lib/search-snippet';
|
||||
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
||||
import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threading';
|
||||
import { buildMdnMessage, type MdnOptions } from '../lib/mdn';
|
||||
@@ -1494,6 +1495,47 @@ export async function searchEmails(
|
||||
return (requireMethodResult(res, '0', 'Email/query').ids as string[]) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The SearchSnippet/get excerpts (RFC 8621 §5) of the rows of a result
|
||||
* page, per group of rows that live in one JMAP account (undefined = the
|
||||
* user's own). One call per account in as few requests as the server's
|
||||
* `maxCallsInRequest` allows. The search filter's terms are loosened first
|
||||
* (see snippetFilterFor). The highlights only decorate the rows, so this
|
||||
* never rejects: an account whose call fails, or a server without the
|
||||
* method, yields `undefined` for that group and the rows keep their plain
|
||||
* subject and preview (webmail `deferredSnippets`).
|
||||
*/
|
||||
export async function getSearchSnippets(
|
||||
filter: Record<string, unknown>,
|
||||
groups: Array<{ accountId?: string; emailIds: string[] }>,
|
||||
): Promise<Array<SearchSnippetResult[] | undefined>> {
|
||||
const out: Array<SearchSnippetResult[] | undefined> = groups.map(() => undefined);
|
||||
const snippetFilter = snippetFilterFor(filter);
|
||||
const calls: Array<{ index: number; call: JMAPMethodCall }> = [];
|
||||
groups.forEach((group, index) => {
|
||||
const accountId = group.accountId ?? jmapClient.accountId;
|
||||
batched(group.emailIds, maxInGet()).forEach((emailIds, part) => {
|
||||
calls.push({ index, call: ['SearchSnippet/get', { accountId, filter: snippetFilter, emailIds }, `${index}:${part}`] });
|
||||
});
|
||||
});
|
||||
const perRequest = Math.max(1, typeof jmapClient.getMaxCallsInRequest === 'function' ? jmapClient.getMaxCallsInRequest() : 16);
|
||||
await Promise.all(batched(calls, perRequest).map(async (chunk) => {
|
||||
let res: Awaited<ReturnType<typeof jmapClient.request>>;
|
||||
try {
|
||||
res = await jmapClient.request(chunk.map((c) => c.call));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const { index, call } of chunk) {
|
||||
try {
|
||||
const list = (requireMethodResult(res, call[2], 'SearchSnippet/get').list as SearchSnippetResult[]) ?? [];
|
||||
out[index] = [...(out[index] ?? []), ...list];
|
||||
} catch { /* this account's rows stay without highlights */ }
|
||||
}
|
||||
}));
|
||||
return out;
|
||||
}
|
||||
|
||||
// Cross-mailbox query with an arbitrary JMAP filter (used by contact activity
|
||||
// to search "from OR to <addresses>" without picking a specific mailbox).
|
||||
export async function queryEmailsByFilter(
|
||||
|
||||
@@ -99,6 +99,13 @@ export interface Email {
|
||||
* belong to the folder's account.
|
||||
*/
|
||||
jmapAccountId?: string;
|
||||
/**
|
||||
* Client-side, never sent by the server: a search hit's SearchSnippet/get
|
||||
* excerpts (RFC 8621 §5) with the matched terms in `<mark>`, HTML-escaped
|
||||
* otherwise. Set only when at least one of them marks something; the row
|
||||
* shows them in place of the subject and preview (src/lib/search-snippet).
|
||||
*/
|
||||
searchSnippet?: { subject: string | null; preview: string | null };
|
||||
}
|
||||
|
||||
export interface MailboxRights {
|
||||
|
||||
@@ -8,6 +8,9 @@ import { keywordPointer, mailboxPointer } from './patch-pointer';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import { freshStoredCredentials } from '../lib/stored-token-refresh';
|
||||
import { textConditions } from '../lib/search-utils';
|
||||
import {
|
||||
filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult, type SnippetBatch,
|
||||
} from '../lib/search-snippet';
|
||||
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
|
||||
|
||||
// Aggregated views across accounts ("All inboxes", "All Sent", All mail /
|
||||
@@ -66,6 +69,13 @@ export interface UnifiedInboxResult {
|
||||
/** Per-target paging cursors to pass back as `positions` for the next page. */
|
||||
positions: Record<string, number>;
|
||||
hasMore: boolean;
|
||||
/**
|
||||
* With a search, the SearchSnippet/get excerpts of this page, already in
|
||||
* flight: one batch per account, keyed by `unifiedSnippetStamp`, for
|
||||
* `withSearchSnippets(emails, batches, unifiedSnippetStamp)`. The page is
|
||||
* listed without waiting for them (webmail 2d67442a2). Never rejects.
|
||||
*/
|
||||
snippets?: Promise<SnippetBatch[]>;
|
||||
}
|
||||
|
||||
export interface UnifiedFetchOptions {
|
||||
@@ -344,13 +354,21 @@ function targetKey(accountId: string, jmapAccountId: string): string {
|
||||
return `${accountId}|${jmapAccountId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account a unified row's search snippets are keyed by: ids repeat
|
||||
* across logins and their shared accounts.
|
||||
*/
|
||||
export function unifiedSnippetStamp(email: UnifiedEmail): string {
|
||||
return targetKey(email.sourceAccountId, email.jmapAccountId);
|
||||
}
|
||||
|
||||
async function fetchTarget(
|
||||
entry: AccountEntry,
|
||||
target: { jmapId: string; isShared: boolean; label?: string },
|
||||
opts: UnifiedFetchOptions,
|
||||
position: number,
|
||||
limit: number,
|
||||
): Promise<{ emails: UnifiedEmail[]; hasMore: boolean }> {
|
||||
): Promise<{ emails: UnifiedEmail[]; hasMore: boolean; snippets?: Promise<SearchSnippetResult[] | undefined> }> {
|
||||
const mailboxes = await mailboxesFor(entry, target.jmapId);
|
||||
const filter = buildFilter(mailboxes, opts);
|
||||
if (!filter) return { emails: [], hasMore: false };
|
||||
@@ -391,7 +409,20 @@ async function fetchTarget(
|
||||
} as UnifiedEmail];
|
||||
});
|
||||
const hasMore = total !== undefined ? position + ids.length < total : ids.length === limit;
|
||||
return { emails, hasMore };
|
||||
// The highlights of a search, in a request of their own so the page
|
||||
// doesn't wait for the server to excerpt every hit.
|
||||
if (emails.length === 0 || !filterHasSnippetTerms(filter)) return { emails, hasMore };
|
||||
const snippets = entry.transport.post([
|
||||
['SearchSnippet/get', {
|
||||
accountId: target.jmapId,
|
||||
filter: snippetFilterFor(filter),
|
||||
emailIds: emails.map((e) => e.id),
|
||||
}, 's'],
|
||||
]).then((sres) => {
|
||||
const [name, body] = sres[0] ?? [];
|
||||
return name === 'SearchSnippet/get' ? ((body?.list as SearchSnippetResult[]) ?? []) : undefined;
|
||||
}).catch(() => undefined);
|
||||
return { emails, hasMore, snippets };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,6 +441,7 @@ export async function fetchUnifiedInbox(
|
||||
const errors: Record<string, string> = {};
|
||||
const positions: Record<string, number> = { ...(opts.positions ?? {}) };
|
||||
let hasMore = false;
|
||||
const snippetBatches: Array<Promise<SnippetBatch | null>> = [];
|
||||
|
||||
const settled = await Promise.all(
|
||||
accountIds.map(async (accountId) => {
|
||||
@@ -428,6 +460,9 @@ export async function fetchUnifiedInbox(
|
||||
const page = await fetchTarget(entry, target, opts, position, perAccountLimit);
|
||||
positions[key] = position + page.emails.length;
|
||||
if (page.hasMore) hasMore = true;
|
||||
if (page.snippets) {
|
||||
snippetBatches.push(page.snippets.then((list) => (list ? { accountId: key, list } : null)));
|
||||
}
|
||||
return page.emails;
|
||||
} catch (err) {
|
||||
// A single inaccessible shared account shouldn't sink the whole
|
||||
@@ -447,7 +482,12 @@ export async function fetchUnifiedInbox(
|
||||
const emails = settled
|
||||
.flat()
|
||||
.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||
return { emails, errors, positions, hasMore };
|
||||
const result: UnifiedInboxResult = { emails, errors, positions, hasMore };
|
||||
if (snippetBatches.length > 0) {
|
||||
result.snippets = Promise.all(snippetBatches)
|
||||
.then((batches) => batches.filter((b): b is SnippetBatch => b !== null));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Actions routed to the owning account ────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text } from 'react-native';
|
||||
import { snippetLineSegments } from '../../lib/search-snippet';
|
||||
import { useResolvedTheme } from '../../theme/colors';
|
||||
|
||||
/**
|
||||
* A SearchSnippet/get excerpt (RFC 8621 §5) with the matched terms
|
||||
* highlighted, as the spans of the row's own Text: nest it inside a Text
|
||||
* that sets the font and `numberOfLines`, so the row keeps its height when
|
||||
* the excerpt replaces the plain subject or preview. The snippet is parsed
|
||||
* into text and marked segments, never rendered as HTML (webmail
|
||||
* components/email/search-snippet-text.tsx).
|
||||
*/
|
||||
export function SearchSnippetText({ snippet }: { snippet: string }) {
|
||||
const dark = useResolvedTheme() === 'dark';
|
||||
const segments = React.useMemo(() => snippetLineSegments(snippet), [snippet]);
|
||||
const markStyle = dark ? styles.markDark : styles.markLight;
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, i) => (
|
||||
segment.marked
|
||||
? <Text key={i} style={markStyle} testID="search-snippet-mark">{segment.text}</Text>
|
||||
: <Text key={i}>{segment.text}</Text>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The webmail's highlight: bg-yellow-200, dark:bg-yellow-500/40, text-inherit.
|
||||
const styles = StyleSheet.create({
|
||||
markLight: { backgroundColor: '#fef08a' },
|
||||
markDark: { backgroundColor: 'rgba(234, 179, 8, 0.4)' },
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
filterHasSnippetTerms,
|
||||
hasMarkedSegment,
|
||||
parseSearchSnippet,
|
||||
snippetFilterFor,
|
||||
snippetHighlight,
|
||||
snippetLineSegments,
|
||||
withSearchSnippets,
|
||||
} from '../search-snippet';
|
||||
import type { Email } from '../../api/types';
|
||||
|
||||
// Ported from the webmail's lib/__tests__/search-snippet.test.ts.
|
||||
|
||||
describe('parseSearchSnippet', () => {
|
||||
it('splits marked and plain segments and decodes the escaped text', () => {
|
||||
const segments = parseSearchSnippet('Re: <mark>Invoice</mark> & receipt <2026>');
|
||||
expect(segments).toEqual([
|
||||
{ text: 'Re: ', marked: false },
|
||||
{ text: 'Invoice', marked: true },
|
||||
{ text: ' & receipt <2026>', marked: false },
|
||||
]);
|
||||
expect(hasMarkedSegment(segments)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats any other tag as literal text', () => {
|
||||
const segments = parseSearchSnippet('<script>x</script> <mark>hit</mark><b>bold</b>');
|
||||
expect(segments).toEqual([
|
||||
{ text: '<script>x</script> ', marked: false },
|
||||
{ text: 'hit', marked: true },
|
||||
{ text: '<b>bold</b>', marked: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports no highlight for a snippet without marks', () => {
|
||||
const segments = parseSearchSnippet('plain subject');
|
||||
expect(segments).toEqual([{ text: 'plain subject', marked: false }]);
|
||||
expect(hasMarkedSegment(segments)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snippetLineSegments', () => {
|
||||
it('puts a multi-line body excerpt on one line without gluing words together', () => {
|
||||
expect(snippetLineSegments('\n Hello\r\n\r\nthe <mark>zephyr</mark>\tlaunch \n')).toEqual([
|
||||
{ text: 'Hello the ', marked: false },
|
||||
{ text: 'zephyr', marked: true },
|
||||
{ text: ' launch', marked: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops segments left empty', () => {
|
||||
expect(snippetLineSegments(' <mark>hit</mark>')).toEqual([{ text: 'hit', marked: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterHasSnippetTerms', () => {
|
||||
it('finds text/subject/body terms at any nesting level', () => {
|
||||
expect(filterHasSnippetTerms({ text: 'foo' })).toBe(true);
|
||||
expect(filterHasSnippetTerms({ operator: 'AND', conditions: [{ inMailbox: 'x' }, { subject: 'foo' }] })).toBe(true);
|
||||
expect(
|
||||
filterHasSnippetTerms({
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'x' }, { operator: 'NOT', conditions: [{ body: 'foo' }] }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores structural-only filters and empty terms', () => {
|
||||
expect(filterHasSnippetTerms(undefined)).toBe(false);
|
||||
expect(filterHasSnippetTerms({ inMailbox: 'x', hasKeyword: '$seen' })).toBe(false);
|
||||
expect(filterHasSnippetTerms({ minSize: 1024 })).toBe(false);
|
||||
expect(filterHasSnippetTerms({ from: 'alice' })).toBe(false);
|
||||
expect(filterHasSnippetTerms({ text: ' ' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snippetFilterFor', () => {
|
||||
it('strips the prefix-search wildcard from highlightable terms only', () => {
|
||||
expect(
|
||||
snippetFilterFor({
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'x' }, { text: 'inv* rece*' }, { from: 'ali*' }],
|
||||
}),
|
||||
).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'x' }, { text: 'inv rece' }, { from: 'ali*' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a lone wildcard alone', () => {
|
||||
expect(snippetFilterFor({ text: '*' })).toEqual({ text: '*' });
|
||||
});
|
||||
|
||||
it('unquotes phrases: one quoted term puts the whole request in exact mode on Stalwart 0.16 and 1.0', () => {
|
||||
expect(
|
||||
snippetFilterFor({
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ text: '"annual report"' },
|
||||
{ text: '2024' },
|
||||
{ subject: "'draft'" },
|
||||
{ from: '"Anna Muster"' },
|
||||
{ inMailbox: 'x' },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ text: 'annual report' },
|
||||
{ text: '2024' },
|
||||
{ subject: 'draft' },
|
||||
{ from: '"Anna Muster"' },
|
||||
{ inMailbox: 'x' },
|
||||
],
|
||||
});
|
||||
expect(snippetFilterFor({ body: '"exact phrase"' })).toEqual({ body: 'exact phrase' });
|
||||
// Only a value wrapped in quotes is a phrase.
|
||||
expect(snippetFilterFor({ text: 'say "hi" twice' })).toEqual({ text: 'say "hi" twice' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('snippetHighlight', () => {
|
||||
it('keeps only the parts that mark something', () => {
|
||||
expect(snippetHighlight({ emailId: 'a', subject: '<mark>hit</mark> a', preview: null }))
|
||||
.toEqual({ subject: '<mark>hit</mark> a', preview: null });
|
||||
expect(snippetHighlight({ emailId: 'b', subject: 's-b', preview: 'nothing marked' })).toBeNull();
|
||||
expect(snippetHighlight({ emailId: 'c', subject: null, preview: 'body <mark>hit</mark>' }))
|
||||
.toEqual({ subject: null, preview: 'body <mark>hit</mark>' });
|
||||
expect(snippetHighlight({ emailId: 'd', subject: '<mark></mark>', preview: undefined })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withSearchSnippets', () => {
|
||||
const email = (id: string, jmapAccountId?: string) => ({ id, subject: `Subject ${id}`, jmapAccountId }) 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');
|
||||
});
|
||||
|
||||
it('reads the account from the given stamp', () => {
|
||||
type Row = Email & { login: string };
|
||||
const emails = [{ ...email('a'), login: 'x' }, { ...email('a'), login: 'y' }] as Row[];
|
||||
const next = withSearchSnippets(emails, [{ accountId: 'y', list: [marked('a')] }], (e) => e.login);
|
||||
expect(next[0].searchSnippet).toBeUndefined();
|
||||
expect(next[1].searchSnippet?.subject).toBe('<mark>Subject</mark> a');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* SearchSnippet/get support (RFC 8621 §5), ported from the webmail's
|
||||
* lib/search-snippet.ts (b408b2597, 5967a1c48, 2d67442a2).
|
||||
*
|
||||
* The server returns, per search hit, the subject and a body excerpt with
|
||||
* the matching terms wrapped in `<mark>` and everything else HTML-escaped
|
||||
* (`&`, `<`, `>`, `"`). The list shows those instead of the
|
||||
* plain subject/preview so the user sees why a mail matched. Nothing here
|
||||
* ever renders HTML: the snippet is split into text/marked segments and the
|
||||
* text is decoded and drawn as Text spans.
|
||||
*/
|
||||
|
||||
import type { Email } from '../api/types';
|
||||
import { decodeEntities } from './verification-code';
|
||||
|
||||
export interface SnippetSegment {
|
||||
text: string;
|
||||
marked: boolean;
|
||||
}
|
||||
|
||||
/** A SearchSnippet object as returned by SearchSnippet/get. */
|
||||
export interface SearchSnippetResult {
|
||||
emailId: string;
|
||||
subject?: string | null;
|
||||
preview?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a snippet into plain and `<mark>`ed segments. Anything that is not
|
||||
* a `<mark>`/`</mark>` token is treated as (escaped) text, so an unexpected
|
||||
* tag in the payload is displayed literally rather than interpreted.
|
||||
*/
|
||||
export function parseSearchSnippet(snippet: string): SnippetSegment[] {
|
||||
const segments: SnippetSegment[] = [];
|
||||
const re = /<\/?mark>/g;
|
||||
let marked = false;
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(snippet)) !== null) {
|
||||
if (match.index > last) {
|
||||
segments.push({ text: decodeEntities(snippet.slice(last, match.index)), marked });
|
||||
}
|
||||
marked = match[0] === '<mark>';
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
if (last < snippet.length) {
|
||||
segments.push({ text: decodeEntities(snippet.slice(last)), marked });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* The segments of a snippet as one line of a list row: whitespace runs
|
||||
* (line breaks of the body excerpt) become one space, the ends are trimmed
|
||||
* and empty segments dropped, like `singleLine` does for the plain preview.
|
||||
*/
|
||||
export function snippetLineSegments(snippet: string): SnippetSegment[] {
|
||||
const segments = parseSearchSnippet(snippet).map((s) => ({ ...s, text: s.text.replace(/\s+/g, ' ') }));
|
||||
if (segments.length > 0) {
|
||||
segments[0].text = segments[0].text.trimStart();
|
||||
segments[segments.length - 1].text = segments[segments.length - 1].text.trimEnd();
|
||||
}
|
||||
return segments.filter((s) => s.text.length > 0);
|
||||
}
|
||||
|
||||
/** True when a segment list carries at least one highlighted term. */
|
||||
export function hasMarkedSegment(segments: SnippetSegment[]): boolean {
|
||||
return segments.some((s) => s.marked && s.text.length > 0);
|
||||
}
|
||||
|
||||
const TERM_PROPERTIES = new Set(['text', 'subject', 'body']);
|
||||
|
||||
/**
|
||||
* Whether a JMAP Email filter contains a term the server can highlight
|
||||
* (`text`, `subject` or `body`, RFC 8621 §5.1). Filters made only of
|
||||
* structural conditions (mailbox, keywords, dates, sizes, addresses) produce
|
||||
* empty snippets, so the extra method call is skipped for them.
|
||||
*/
|
||||
export function filterHasSnippetTerms(filter: Record<string, unknown> | undefined): boolean {
|
||||
if (!filter) return false;
|
||||
const conditions = filter.conditions;
|
||||
if (Array.isArray(conditions)) {
|
||||
return conditions.some((c) => filterHasSnippetTerms(c as Record<string, unknown>));
|
||||
}
|
||||
return Object.entries(filter).some(
|
||||
([key, value]) => TERM_PROPERTIES.has(key) && typeof value === 'string' && value.trim().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter to hand SearchSnippet/get: the query filter with the
|
||||
* highlightable terms loosened. As soon as one term is quoted, Stalwart
|
||||
* (0.16 and 1.0) marks text only where the words of all terms appear in a
|
||||
* row, so a phrase ANDed with other parts of the query would mark nothing.
|
||||
* Phrases are therefore unquoted and their words marked wherever they
|
||||
* occur. A trailing `*` (a typed prefix) is dropped as well, so the
|
||||
* server's tokenizer sees the word itself. Structural conditions are passed
|
||||
* through unchanged.
|
||||
*/
|
||||
export function snippetFilterFor(filter: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (key === 'conditions' && Array.isArray(value)) {
|
||||
out[key] = value.map((c) => snippetFilterFor(c as Record<string, unknown>));
|
||||
} else if (TERM_PROPERTIES.has(key) && typeof value === 'string') {
|
||||
const phrase = /^\s*(["'])([\s\S]*)\1\s*$/.exec(value);
|
||||
out[key] = phrase
|
||||
? phrase[2]
|
||||
: value
|
||||
.split(/\s+/)
|
||||
.map((word) => (word.length > 1 && word.endsWith('*') ? word.slice(0, -1) : word))
|
||||
.join(' ');
|
||||
} else {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The snippets of one JMAP account's rows. Ids repeat across accounts, so a
|
||||
* list spanning accounts keeps each account's snippets to the rows stamped
|
||||
* with it (`Email.jmapAccountId`); no `accountId` matches every row (the
|
||||
* rows of a single folder carry no stamp).
|
||||
*/
|
||||
export interface SnippetBatch {
|
||||
accountId?: string;
|
||||
list: SearchSnippetResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The highlight of one row, or null when the snippet marks nothing (the
|
||||
* server found nothing to mark, or returned the plain subject): the row
|
||||
* then keeps its regular subject and preview.
|
||||
*/
|
||||
export function snippetHighlight(snippet: SearchSnippetResult): Email['searchSnippet'] | null {
|
||||
const subject = snippet.subject && hasMarkedSegment(parseSearchSnippet(snippet.subject)) ? snippet.subject : null;
|
||||
const preview = snippet.preview && hasMarkedSegment(parseSearchSnippet(snippet.preview)) ? snippet.preview : null;
|
||||
return subject || preview ? { subject, preview } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The snippets attached to a list that is already on screen: a new list
|
||||
* with new objects for the rows 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<T extends Email>(
|
||||
emails: T[],
|
||||
batches: SnippetBatch[],
|
||||
stampOf: (email: T) => string | undefined = (email) => email.jmapAccountId,
|
||||
): T[] {
|
||||
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 && stampOf(email) !== accountId) continue;
|
||||
const snippet = byId.get(email.id);
|
||||
if (!snippet) continue;
|
||||
const highlight = snippetHighlight(snippet);
|
||||
if (!highlight) return email;
|
||||
changed = true;
|
||||
return { ...email, searchSnippet: highlight };
|
||||
}
|
||||
return email;
|
||||
});
|
||||
return changed ? next : emails;
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import { getContactDisplayName } from '../lib/contact-utils';
|
||||
import { formatListDate } from '../lib/date-format';
|
||||
import { singleLine } from '../lib/single-line';
|
||||
import { cleanPreview } from '../lib/preview-text';
|
||||
import { SearchSnippetText } from '../components/email/SearchSnippetText';
|
||||
import {
|
||||
findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes,
|
||||
} from '../lib/mailbox-tree';
|
||||
@@ -299,8 +300,11 @@ const EmailRow = React.memo(function EmailRow({
|
||||
|
||||
{/* Row 2: Subject + tag pills */}
|
||||
<View style={styles.subjectRow}>
|
||||
{/* A search hit shows the server's excerpt with the matched terms marked. */}
|
||||
<Text style={[styles.emailSubject, dyn.body, unread && styles.textBold]} numberOfLines={1}>
|
||||
{singleLine(item.subject) || tr('email_viewer.no_subject', '(No Subject)')}
|
||||
{item.searchSnippet?.subject
|
||||
? <SearchSnippetText snippet={item.searchSnippet.subject} />
|
||||
: singleLine(item.subject) || tr('email_viewer.no_subject', '(No Subject)')}
|
||||
</Text>
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
<View key={tag.id} style={[styles.tagPill, { backgroundColor: tag.bg }]}>
|
||||
@@ -316,7 +320,9 @@ const EmailRow = React.memo(function EmailRow({
|
||||
{/* Row 3: Preview - hidden in compact density modes regardless of toggle */}
|
||||
{showPreview && density.showPreview && (
|
||||
<Text style={[styles.emailPreview, dyn.body]} numberOfLines={2}>
|
||||
{singleLine(cleanPreview(item.preview))}
|
||||
{item.searchSnippet?.preview
|
||||
? <SearchSnippetText snippet={item.searchSnippet.preview} />
|
||||
: singleLine(cleanPreview(item.preview))}
|
||||
</Text>
|
||||
)}
|
||||
{verificationCode && <VerificationCodeChip code={verificationCode} disabled={selectionMode} inList />}
|
||||
|
||||
@@ -15,8 +15,10 @@ import SenderAvatar from '../components/SenderAvatar';
|
||||
import { SwipeableRow } from '../components/SwipeableRow';
|
||||
import {
|
||||
fetchUnifiedInbox, patchUnifiedKeywords, moveUnifiedEmails, deleteUnifiedEmails, isInUnifiedTrash,
|
||||
type UnifiedEmail, type UnifiedRole, type CrossView,
|
||||
unifiedSnippetStamp, type UnifiedEmail, type UnifiedRole, type CrossView, type UnifiedInboxResult,
|
||||
} from '../api/unified-inbox';
|
||||
import { withSearchSnippets } from '../lib/search-snippet';
|
||||
import { SearchSnippetText } from '../components/email/SearchSnippetText';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
|
||||
@@ -103,6 +105,15 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
[includeGroup, query, role, view],
|
||||
);
|
||||
|
||||
// A search page's highlights, once they land, while that search is still
|
||||
// the one on screen (a new load bumps `loadSeq`).
|
||||
const highlight = React.useCallback((result: UnifiedInboxResult, seq: number) => {
|
||||
void result.snippets?.then((batches) => {
|
||||
if (seq !== loadSeq.current) return;
|
||||
setEmails((prev) => withSearchSnippets(prev, batches, unifiedSnippetStamp));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
const seq = ++loadSeq.current;
|
||||
const active = useAuthStore.getState().activeAccountId;
|
||||
@@ -115,10 +126,11 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
hasMoreRef.current = result.hasMore;
|
||||
setEmails(result.emails);
|
||||
setErrors(result.errors);
|
||||
highlight(result, seq);
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoading(false);
|
||||
}
|
||||
}, [accountIds, fetchOpts, reloadTracker]);
|
||||
}, [accountIds, fetchOpts, reloadTracker, highlight]);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (loading || loadingMore || !hasMoreRef.current) return;
|
||||
@@ -138,10 +150,11 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
for (const e of result.emails) if (!seen.has(rowKey(e))) merged.push(e);
|
||||
return merged.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||
});
|
||||
highlight(result, seq);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [accountIds, fetchOpts, loading, loadingMore]);
|
||||
}, [accountIds, fetchOpts, loading, loadingMore, highlight]);
|
||||
|
||||
// Reload whenever the inputs change, and on coming back into focus only
|
||||
// when something may have changed meanwhile (a message read or deleted in
|
||||
@@ -373,7 +386,9 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
<Text style={[styles.time, dyn.caption]}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale, t })}</Text>
|
||||
</View>
|
||||
<Text style={[styles.subject, dyn.body, unread && styles.bold]} numberOfLines={1}>
|
||||
{singleLine(item.subject) || t('email_viewer.no_subject', '(No Subject)')}
|
||||
{item.searchSnippet?.subject
|
||||
? <SearchSnippetText snippet={item.searchSnippet.subject} />
|
||||
: singleLine(item.subject) || t('email_viewer.no_subject', '(No Subject)')}
|
||||
</Text>
|
||||
<View style={styles.line}>
|
||||
{acc && accountIds.length > 1 && (
|
||||
@@ -396,8 +411,12 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
)}
|
||||
</View>
|
||||
{/* Same settings as the folder list: preview on/off, density, text size. */}
|
||||
{item.preview && showPreview && density.showPreview ? (
|
||||
<Text style={[styles.preview, dyn.body]} numberOfLines={2}>{singleLine(cleanPreview(item.preview))}</Text>
|
||||
{(item.searchSnippet?.preview || item.preview) && showPreview && density.showPreview ? (
|
||||
<Text style={[styles.preview, dyn.body]} numberOfLines={2}>
|
||||
{item.searchSnippet?.preview
|
||||
? <SearchSnippetText snippet={item.searchSnippet.preview} />
|
||||
: singleLine(cleanPreview(item.preview))}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// The folder list and the unified views show a search hit's excerpts in
|
||||
// place of its subject and preview (webmail thread-list-item, b408b2597).
|
||||
// They are drawn as spans of the row's own one- or two-line Text, so the
|
||||
// row keeps its height when they arrive, and never as HTML. The screens
|
||||
// aren't rendered in these tests, so their source is checked.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const SRC = join(__dirname, '..', '..');
|
||||
const read = (file: string) => readFileSync(join(SRC, file), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
// The Text element that renders `expr` and the attributes it opens with.
|
||||
function textAround(source: string, expr: string): string | null {
|
||||
const at = source.indexOf(`<SearchSnippetText snippet={${expr}} />`);
|
||||
if (at < 0) return null;
|
||||
const open = source.lastIndexOf('<Text ', at);
|
||||
return source.slice(open, source.indexOf('>', open) + 1);
|
||||
}
|
||||
|
||||
describe.each([
|
||||
['screens/EmailListScreen.tsx'],
|
||||
['screens/UnifiedInboxScreen.tsx'],
|
||||
])('%s', (file) => {
|
||||
const source = read(file);
|
||||
|
||||
it('shows the subject excerpt on the one subject line', () => {
|
||||
expect(textAround(source, 'item.searchSnippet.subject')).toMatch(/numberOfLines=\{1\}/);
|
||||
});
|
||||
|
||||
it('shows the body excerpt on the two preview lines', () => {
|
||||
expect(textAround(source, 'item.searchSnippet.preview')).toMatch(/numberOfLines=\{2\}/);
|
||||
});
|
||||
|
||||
it('never renders the excerpt as HTML', () => {
|
||||
expect(source).not.toMatch(/dangerouslySet|innerHTML/);
|
||||
});
|
||||
});
|
||||
@@ -154,6 +154,9 @@ function seed() {
|
||||
}
|
||||
|
||||
const ids = () => useEmailStore.getState().emails.map((e) => e.id);
|
||||
// The requests that load the list: a search's highlights follow in a request
|
||||
// of their own (SearchSnippet/get), which these tests don't count.
|
||||
const listRequests = () => server.current!.requests.filter((r) => !r.every(([n]) => n === 'SearchSnippet/get'));
|
||||
|
||||
// "All folders" picked explicitly, unless a test picked a folder: the default
|
||||
// scope also leaves Trash and Junk out (see "the default search scope").
|
||||
@@ -199,7 +202,7 @@ describe('the default search scope', () => {
|
||||
await searchDefault('zephyr');
|
||||
|
||||
expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3']);
|
||||
expect(server.current!.requests).toHaveLength(1);
|
||||
expect(listRequests()).toHaveLength(1);
|
||||
const queries = server.current!.callsOf('Email/query');
|
||||
expect(queries.map(([, args]) => [args.accountId, args.filter])).toEqual([
|
||||
['c', { operator: 'AND', conditions: [{ text: 'zephyr' }, { inMailboxOtherThan: ['trash', 'junk'] }] }],
|
||||
@@ -229,7 +232,7 @@ describe('"All folders" search across the own and the team account (#1082)', ()
|
||||
|
||||
expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3', 'm5']);
|
||||
expect(useEmailStore.getState().totalEmails).toBe(8);
|
||||
expect(server.current!.requests).toHaveLength(1);
|
||||
expect(listRequests()).toHaveLength(1);
|
||||
const queries = server.current!.callsOf('Email/query');
|
||||
expect(queries.map(([, args]) => args.accountId)).toEqual(['c', 'team']);
|
||||
for (const [, args] of queries) expect(args.filter).toEqual({ text: 'zephyr' });
|
||||
@@ -272,18 +275,18 @@ describe('"All folders" search across the own and the team account (#1082)', ()
|
||||
expect(ids()).toEqual(['m1', 'o1', 'm2', 'o2']);
|
||||
|
||||
await useEmailStore.getState().loadMoreEmails();
|
||||
const page2 = server.current!.requests[1].filter(([n]) => n === 'Email/query');
|
||||
const page2 = listRequests()[1].filter(([n]) => n === 'Email/query');
|
||||
expect(page2.map(([, a]) => [a.accountId, a.position])).toEqual([['c', 2], ['team', 2]]);
|
||||
expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3']);
|
||||
|
||||
await useEmailStore.getState().loadMoreEmails();
|
||||
const page3 = server.current!.requests[2].filter(([n]) => n === 'Email/query');
|
||||
const page3 = listRequests()[2].filter(([n]) => n === 'Email/query');
|
||||
expect(page3.map(([, a]) => [a.accountId, a.position])).toEqual([['c', 3], ['team', 4]]);
|
||||
expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3', 'm5']);
|
||||
|
||||
// Everything is loaded: no further request.
|
||||
await useEmailStore.getState().loadMoreEmails();
|
||||
expect(server.current!.requests).toHaveLength(3);
|
||||
expect(listRequests()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('shows the reachable account\'s hits when the other one fails', async () => {
|
||||
@@ -647,7 +650,7 @@ describe('tag view across the own and the team account (#1038)', () => {
|
||||
await openTag('team:t-inbox');
|
||||
|
||||
expect(ids()).toEqual(['m1', 'o1', 'm2', 'o2']);
|
||||
expect(server.current!.requests).toHaveLength(1);
|
||||
expect(listRequests()).toHaveLength(1);
|
||||
const queries = server.current!.callsOf('Email/query');
|
||||
// Each account leaves its own Trash and Junk out (webmail #1156).
|
||||
expect(queries.map(([, a]) => [a.accountId, a.filter])).toEqual([
|
||||
@@ -670,7 +673,7 @@ describe('tag view across the own and the team account (#1038)', () => {
|
||||
// one prefix containing a space and found nothing (P1-7).
|
||||
await vi.waitFor(() => expect(ids()).toEqual(['m2', 'o2']));
|
||||
|
||||
const last = server.current!.requests.at(-1)!.filter(([n]) => n === 'Email/query');
|
||||
const last = listRequests().at(-1)!.filter(([n]) => n === 'Email/query');
|
||||
expect(last.map(([, a]) => a.filter)).toEqual([
|
||||
{ operator: 'AND', conditions: [{ text: 'red 2' }, { hasKeyword: RED }, { inMailboxOtherThan: ['trash', 'junk'] }] },
|
||||
{ operator: 'AND', conditions: [{ text: 'red 2' }, { hasKeyword: RED }, { inMailboxOtherThan: ['t-trash', 't-junk'] }] },
|
||||
@@ -689,7 +692,7 @@ describe('tag view across the own and the team account (#1038)', () => {
|
||||
await openTag('inbox');
|
||||
useEmailStore.getState().setSearchQuery('"red 2" notes');
|
||||
await vi.waitFor(() => {
|
||||
const last = server.current!.requests.at(-1)!.filter(([n]) => n === 'Email/query');
|
||||
const last = listRequests().at(-1)!.filter(([n]) => n === 'Email/query');
|
||||
expect(last[0][1].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ text: '"red 2"' }, { text: 'notes' }, { hasKeyword: RED }, { inMailboxOtherThan: ['trash', 'junk'] }],
|
||||
@@ -719,7 +722,7 @@ describe('tag view across the own and the team account (#1038)', () => {
|
||||
|
||||
await useEmailStore.getState().loadMoreEmails();
|
||||
|
||||
const page2 = server.current!.requests[1].filter(([n]) => n === 'Email/query');
|
||||
const page2 = listRequests()[1].filter(([n]) => n === 'Email/query');
|
||||
expect(page2.map(([, a]) => [a.accountId, a.position])).toEqual([['c', 1], ['team', 1]]);
|
||||
expect(ids()).toEqual(['m1', 'o1', 'm2', 'o2']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createFakeJmap, type FakeEmail } from './fake-jmap-server';
|
||||
|
||||
// Search hits show the server's SearchSnippet/get excerpts with the matched
|
||||
// terms marked (webmail b408b2597, 2d67442a2). The page is listed first and
|
||||
// the excerpts follow in one request per page, one call per account; they
|
||||
// land only while the same search is still on screen, and in a list that
|
||||
// spans accounts each account's excerpts reach its own rows only.
|
||||
|
||||
const server = vi.hoisted(() => ({ current: null as null | ReturnType<typeof import('./fake-jmap-server').createFakeJmap> }));
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
isConnected: true,
|
||||
accountId: 'c',
|
||||
connectedAccountId: 'c',
|
||||
username: 'me@example.com',
|
||||
serverUrl: 'https://mail.example.com',
|
||||
currentSession: {
|
||||
apiUrl: 'https://mail.example.com/jmap/',
|
||||
accounts: {
|
||||
c: { accountCapabilities: { 'urn:ietf:params:jmap:mail': { emailQuerySortOptions: ['receivedAt'] } } },
|
||||
team: { accountCapabilities: { 'urn:ietf:params:jmap:mail': { emailQuerySortOptions: ['receivedAt'] } } },
|
||||
},
|
||||
},
|
||||
getMaxObjectsInGet: () => 500,
|
||||
getMaxObjectsInSet: () => 500,
|
||||
getMaxCallsInRequest: () => 16,
|
||||
getSharedMailAccounts: () => [{ id: 'team', name: 'Team' }],
|
||||
request: vi.fn((calls) => server.current!.request(calls)),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../locale-store', () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
useLocaleStore: { getState: () => ({ locale: 'en', t: (_k: string, f?: string) => f ?? _k }) },
|
||||
}));
|
||||
|
||||
vi.mock('../outbox-store', () => ({
|
||||
applyOrQueueBatch: async (_ops: unknown[], onlineRun?: () => Promise<void>) => { if (onlineRun) await onlineRun(); return { queued: false }; },
|
||||
applyOrQueue: async (_op: unknown, onlineRun?: () => Promise<void>) => { if (onlineRun) await onlineRun(); return { queued: false }; },
|
||||
useOutboxStore: { getState: () => ({ setAccount: vi.fn(async () => undefined), flush: vi.fn(async () => undefined) }) },
|
||||
}));
|
||||
|
||||
vi.mock('../settings-store', () => {
|
||||
const settings: Record<string, unknown> = {};
|
||||
return {
|
||||
useSettingsStore: {
|
||||
getState: () => ({
|
||||
...settings,
|
||||
updateSetting: (key: string, value: unknown) => { settings[key] = value; },
|
||||
}),
|
||||
},
|
||||
__settings: settings,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../offline-cache-store', () => ({
|
||||
useOfflineCacheStore: {
|
||||
getState: () => ({
|
||||
hydrated: true,
|
||||
hydrate: vi.fn(),
|
||||
setAccount: vi.fn(async () => undefined),
|
||||
totalCount: () => 0,
|
||||
getEmailsInMailbox: vi.fn(async () => []),
|
||||
has: () => false,
|
||||
get: vi.fn(async () => null),
|
||||
put: vi.fn(async () => undefined),
|
||||
patch: vi.fn(async () => undefined),
|
||||
remove: vi.fn(async () => undefined),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { generateAccountId } from '../../lib/account-utils';
|
||||
import * as settingsModule from '../settings-store';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import type { Email, Mailbox } from '../../api/types';
|
||||
|
||||
const settings = (settingsModule as unknown as { __settings: Record<string, unknown> }).__settings;
|
||||
|
||||
function mb(id: string, role: string | null, shared?: { raw: string }): Mailbox {
|
||||
return {
|
||||
id,
|
||||
name: role ?? id,
|
||||
role,
|
||||
totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
|
||||
myRights: {} as Mailbox['myRights'],
|
||||
...(shared
|
||||
? { originalId: shared.raw, accountId: 'team', accountName: 'Team', isShared: true }
|
||||
: { accountId: 'c', isShared: false }),
|
||||
};
|
||||
}
|
||||
|
||||
const MAILBOXES: Mailbox[] = [
|
||||
mb('inbox', 'inbox'), mb('archive', 'archive'), mb('trash', 'trash'),
|
||||
mb('team:t-inbox', 'inbox', { raw: 't-inbox' }),
|
||||
];
|
||||
|
||||
function mail(id: string, folder: string, day: number, subject: string, extra: Partial<FakeEmail> = {}): Email {
|
||||
return {
|
||||
id,
|
||||
size: 1,
|
||||
hasAttachment: false,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: { [folder]: true },
|
||||
keywords: {},
|
||||
receivedAt: `2026-09-${String(day).padStart(2, '0')}T10:00:00Z`,
|
||||
subject,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function seed() {
|
||||
return createFakeJmap({
|
||||
c: [
|
||||
mail('a', 'inbox', 20, 'Zephyr launch', { preview: 'The <launch> is on & off' }),
|
||||
mail('b', 'inbox', 19, 'Lunch', { preview: 'zephyr is mentioned in the body only' }),
|
||||
mail('c1', 'archive', 14, 'Quarterly numbers', { preview: 'nothing to see' }),
|
||||
],
|
||||
team: [
|
||||
// Same id as the own account's "a": ids only are unique per account.
|
||||
mail('a', 't-inbox', 22, 'Team sync', { preview: 'agenda for the zephyr review' }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const rows = () => useEmailStore.getState().emails;
|
||||
const row = (id: string, stamp?: string) => rows().find((e) => e.id === id && (stamp === undefined || e.jmapAccountId === stamp));
|
||||
const snippetCalls = () => server.current!.callsOf('SearchSnippet/get');
|
||||
|
||||
async function settle() {
|
||||
await vi.waitFor(() => expect(useEmailStore.getState().loading).toBe(false));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
server.current = seed();
|
||||
for (const key of Object.keys(settings)) delete settings[key];
|
||||
Object.assign(settings, {
|
||||
emailsPerPage: 25,
|
||||
mailSortAscending: false,
|
||||
disableThreading: true,
|
||||
deleteAction: 'trash',
|
||||
permanentlyDeleteJunk: false,
|
||||
archiveMode: 'single',
|
||||
messageListOrder: [],
|
||||
messageListOrderScope: 'inbox',
|
||||
});
|
||||
useEmailStore.getState().reset();
|
||||
useEmailStore.setState({
|
||||
activeAccountId: generateAccountId('me@example.com', 'https://mail.example.com'),
|
||||
mailboxes: MAILBOXES,
|
||||
currentMailboxId: 'inbox',
|
||||
pendingUndo: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('search snippets in a folder search', () => {
|
||||
it('marks the matched terms of each hit, asked once for the whole page', async () => {
|
||||
useEmailStore.setState({ filters: { folder: 'current' } });
|
||||
useEmailStore.getState().setSearchQuery('zephyr');
|
||||
await settle();
|
||||
expect(rows().map((e) => e.id)).toEqual(['a', 'b']);
|
||||
|
||||
await vi.waitFor(() => expect(row('b')?.searchSnippet).toBeDefined());
|
||||
expect(row('a')?.searchSnippet).toEqual({ subject: '<mark>Zephyr</mark> launch', preview: null });
|
||||
expect(row('b')?.searchSnippet).toEqual({ subject: null, preview: '<mark>zephyr</mark> is mentioned in the body only' });
|
||||
// One call for the page, sent on its own after the page was listed.
|
||||
expect(snippetCalls()).toHaveLength(1);
|
||||
const [, args] = snippetCalls()[0];
|
||||
expect(args).toEqual({ accountId: 'c', filter: { text: 'zephyr' }, emailIds: ['a', 'b'] });
|
||||
const snippetRequest = server.current!.requests.find((r) => r.some(([n]) => n === 'SearchSnippet/get'))!;
|
||||
expect(snippetRequest.map(([n]) => n)).toEqual(['SearchSnippet/get']);
|
||||
});
|
||||
|
||||
it('loosens phrases and prefixes so the server marks their words', async () => {
|
||||
useEmailStore.setState({ filters: { folder: 'current' } });
|
||||
useEmailStore.getState().setSearchQuery('"zephyr launch" laun*');
|
||||
await settle();
|
||||
await vi.waitFor(() => expect(snippetCalls()).toHaveLength(1));
|
||||
expect(snippetCalls()[0][1].filter).toEqual({
|
||||
operator: 'AND', conditions: [{ text: 'zephyr launch' }, { text: 'laun' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('asks nothing for a filter without words to mark', async () => {
|
||||
useEmailStore.getState().setFilters({ isUnread: true, folder: 'current' });
|
||||
await settle();
|
||||
expect(rows().length).toBeGreaterThan(0);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(snippetCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops the excerpts of a search the user has since changed', async () => {
|
||||
const release = server.current!.holdSnippets();
|
||||
useEmailStore.setState({ filters: { folder: 'current' } });
|
||||
useEmailStore.getState().setSearchQuery('zephyr');
|
||||
await settle();
|
||||
await vi.waitFor(() => expect(snippetCalls()).toHaveLength(1));
|
||||
|
||||
// The unread filter lists the same messages; they must not show the
|
||||
// search's excerpts once it is gone.
|
||||
useEmailStore.setState({ searchQuery: '', filters: { folder: 'current', isUnread: true } });
|
||||
await useEmailStore.getState().refreshEmails();
|
||||
expect(rows().map((e) => e.id)).toEqual(['a', 'b']);
|
||||
release();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(snippetCalls()).toHaveLength(1);
|
||||
expect(row('a')?.searchSnippet).toBeUndefined();
|
||||
expect(row('b')?.searchSnippet).toBeUndefined();
|
||||
});
|
||||
|
||||
it('highlights the rows load-more brings', async () => {
|
||||
settings.emailsPerPage = 1;
|
||||
useEmailStore.setState({ filters: { folder: 'current' } });
|
||||
useEmailStore.getState().setSearchQuery('zephyr');
|
||||
await settle();
|
||||
expect(rows().map((e) => e.id)).toEqual(['a']);
|
||||
await useEmailStore.getState().loadMoreEmails();
|
||||
expect(rows().map((e) => e.id)).toEqual(['a', 'b']);
|
||||
await vi.waitFor(() => expect(row('b')?.searchSnippet?.preview).toContain('<mark>zephyr</mark>'));
|
||||
expect(snippetCalls().map(([, args]) => args.emailIds)).toEqual([['a'], ['b']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search snippets across accounts', () => {
|
||||
it("asks every account in one request and keeps each account's excerpts to its own rows", async () => {
|
||||
useEmailStore.setState({ filters: { folder: 'all' } });
|
||||
useEmailStore.getState().setSearchQuery('zephyr');
|
||||
await settle();
|
||||
expect(rows().map((e) => `${e.jmapAccountId}:${e.id}`)).toEqual(['team:a', 'c:a', 'c:b']);
|
||||
|
||||
await vi.waitFor(() => expect(row('a', 'team')?.searchSnippet).toBeDefined());
|
||||
await vi.waitFor(() => expect(row('b', 'c')?.searchSnippet).toBeDefined());
|
||||
expect(row('a', 'team')?.searchSnippet).toEqual({ subject: null, preview: 'agenda for the <mark>zephyr</mark> review' });
|
||||
expect(row('a', 'c')?.searchSnippet).toEqual({ subject: '<mark>Zephyr</mark> launch', preview: null });
|
||||
|
||||
const snippetRequests = server.current!.requests.filter((r) => r.some(([n]) => n === 'SearchSnippet/get'));
|
||||
expect(snippetRequests).toHaveLength(1);
|
||||
expect(snippetRequests[0].map(([, args]) => [args.accountId, args.emailIds])).toEqual([
|
||||
['team', ['a']],
|
||||
['c', ['a', 'b']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the results when one account cannot excerpt', async () => {
|
||||
const release = server.current!.holdSnippets();
|
||||
useEmailStore.setState({ filters: { folder: 'all' } });
|
||||
useEmailStore.getState().setSearchQuery('zephyr');
|
||||
await settle();
|
||||
await vi.waitFor(() => expect(snippetCalls()).toHaveLength(2));
|
||||
// The team account goes away between the query and the excerpts.
|
||||
server.current!.failing.set('team', 'forbidden');
|
||||
release();
|
||||
await vi.waitFor(() => expect(row('b', 'c')?.searchSnippet).toBeDefined());
|
||||
expect(row('a', 'c')?.searchSnippet?.subject).toBe('<mark>Zephyr</mark> launch');
|
||||
expect(row('a', 'team')?.searchSnippet).toBeUndefined();
|
||||
expect(rows()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
// A small in-memory JMAP server for store tests that assert the wire: it
|
||||
// answers Email/query (text / inMailbox / hasKeyword / notKeyword filters,
|
||||
// newest first, position + limit), Email/get and Thread/get (including
|
||||
// result references), and applies Email/set updates and destroys. Every
|
||||
// answers Email/query (text / inMailbox / hasKeyword / notKeyword / minSize /
|
||||
// maxSize filters, newest first, position + limit), Email/get, Thread/get
|
||||
// (including result references) and SearchSnippet/get (every word of the
|
||||
// filter's text/subject/body terms wrapped in <mark>), and applies Email/set
|
||||
// updates and destroys. Every
|
||||
// request is recorded so a test can check which method calls went out, and
|
||||
// in how many requests. Text conditions are matched the way Stalwart's
|
||||
// built-in index does (see lib/__tests__/stalwart-text-model), 1.0 unless
|
||||
@@ -23,10 +25,40 @@ function matches(email: FakeEmail, filter: Filter | undefined, textModel: Stalwa
|
||||
if (key === 'inMailboxOtherThan') return !(value as string[]).some((id) => email.mailboxIds[id]);
|
||||
if (key === 'hasKeyword') return !!email.keywords[value as string];
|
||||
if (key === 'notKeyword') return !email.keywords[value as string];
|
||||
if (key === 'minSize') return (email.size ?? 0) >= (value as number);
|
||||
if (key === 'maxSize') return (email.size ?? 0) < (value as number);
|
||||
throw new Error(`fake server: unsupported filter ${key}`);
|
||||
});
|
||||
}
|
||||
|
||||
// The words of a filter's highlightable terms, lower-cased.
|
||||
function snippetWords(filter: Filter | undefined, out = new Set<string>()): Set<string> {
|
||||
if (!filter) return out;
|
||||
if (Array.isArray(filter.conditions)) {
|
||||
for (const c of filter.conditions as Filter[]) snippetWords(c, out);
|
||||
return out;
|
||||
}
|
||||
for (const key of ['text', 'subject', 'body']) {
|
||||
const value = filter[key];
|
||||
if (typeof value !== 'string') continue;
|
||||
for (const w of value.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []) out.add(w);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// `text` HTML-escaped, with every word in `words` wrapped in <mark>; null
|
||||
// when nothing matched (as Stalwart answers for a field without a hit).
|
||||
function markWords(text: string, words: Set<string>): string | null {
|
||||
let hit = false;
|
||||
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const marked = escaped.replace(/[\p{L}\p{N}]+/gu, (w) => {
|
||||
if (!words.has(w.toLowerCase())) return w;
|
||||
hit = true;
|
||||
return `<mark>${w}</mark>`;
|
||||
});
|
||||
return hit ? marked : null;
|
||||
}
|
||||
|
||||
function unescapePointer(segment: string): string {
|
||||
return segment.replace(/~1/g, '/').replace(/~0/g, '~');
|
||||
}
|
||||
@@ -41,9 +73,18 @@ export function createFakeJmap(
|
||||
const failing = new Map<string, string>();
|
||||
/** JMAP account id → messages whose Email/set updates it refuses as `forbidden`. */
|
||||
const refusing = new Map<string, Set<string>>();
|
||||
/** While set, requests carrying a SearchSnippet/get wait for it. */
|
||||
let snippetGate: Promise<void> | null = null;
|
||||
/** Hold every SearchSnippet/get answer until the returned function is called. */
|
||||
const holdSnippets = () => {
|
||||
let release!: () => void;
|
||||
snippetGate = new Promise<void>((resolve) => { release = resolve; });
|
||||
return () => { snippetGate = null; release(); };
|
||||
};
|
||||
|
||||
const request = async (calls: JMAPMethodCall[]) => {
|
||||
requests.push(calls);
|
||||
if (snippetGate && calls.some(([name]) => name === 'SearchSnippet/get')) await snippetGate;
|
||||
const responses: Array<[string, Record<string, any>, string]> = [];
|
||||
const byCallId = new Map<string, [string, Record<string, any>, string]>();
|
||||
for (const [name, args, callId] of calls) {
|
||||
@@ -91,6 +132,14 @@ export function createFakeJmap(
|
||||
emailIds: store.filter((e) => e.threadId === id).map((e) => e.id),
|
||||
}));
|
||||
response = ['Thread/get', { accountId, list, state: 't', notFound: [] }, callId];
|
||||
} else if (name === 'SearchSnippet/get') {
|
||||
const words = snippetWords(args.filter as Filter);
|
||||
const list = ((args.emailIds ?? []) as string[]).flatMap((id) => {
|
||||
const email = store.find((e) => e.id === id);
|
||||
if (!email) return [];
|
||||
return [{ emailId: id, subject: markWords(email.subject ?? '', words), preview: markWords(email.preview ?? '', words) }];
|
||||
});
|
||||
response = ['SearchSnippet/get', { accountId, list, notFound: [] }, callId];
|
||||
} else if (name === 'Email/set') {
|
||||
const updated: Record<string, null> = {};
|
||||
const notUpdated: Record<string, { type: string }> = {};
|
||||
@@ -137,5 +186,5 @@ export function createFakeJmap(
|
||||
/** The calls of `name` sent so far. */
|
||||
const callsOf = (name: string) => calls().filter(([n]) => n === name);
|
||||
|
||||
return { request, requests, calls, callsOf, failing, refusing, accounts };
|
||||
return { request, requests, calls, callsOf, failing, refusing, accounts, holdSnippets };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getThreads,
|
||||
getFullEmail,
|
||||
importEmailBlob,
|
||||
getSearchSnippets,
|
||||
patchKeywordsForEmails,
|
||||
patchKeywordsPerEmail,
|
||||
moveEmail,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
mailboxesForSiblingOf, mailboxesOfAccount, findJunkMailbox, findArchiveMailbox, findTrashMailbox, ownMailboxes,
|
||||
} from '../lib/mailbox-tree';
|
||||
import { textConditions } from '../lib/search-utils';
|
||||
import { filterHasSnippetTerms, withSearchSnippets, type SnippetBatch } from '../lib/search-snippet';
|
||||
import { collapseThreads, rowKeyOf } from '../lib/thread-utils';
|
||||
import { compareEmails, levelKeyword, orderForMailbox, sanitizeSortLevels, type SortLevel } from '../lib/message-list-order';
|
||||
import { buildListSort, markKeywordSortUnsupported } from '../lib/keyword-sort-polarity';
|
||||
@@ -726,6 +728,56 @@ function listComparator(state: EmailState): (a: Email, b: Email) => number {
|
||||
return compareEmails(levels, { pinnedFirst: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights the rows of a search page once their SearchSnippet/get lands
|
||||
* (webmail highlightWhenSnippetsArrive, 2d67442a2). The page is listed
|
||||
* first and the excerpts follow in a request of their own, one call per
|
||||
* account of the page: chained onto the query they held the whole page back
|
||||
* while the server excerpted every hit. They are applied only while the same
|
||||
* search is still on screen in the same account; matching ids would not do,
|
||||
* since a folder opened meanwhile can hold the same messages and must not
|
||||
* show search excerpts. Rows of a list spanning accounts are stamped with
|
||||
* their account and get that account's snippets only; the rows of one folder
|
||||
* are asked in `accountId` (undefined = the user's own).
|
||||
*/
|
||||
function highlightSearchHits(
|
||||
view: Pick<EmailState, 'searchQuery' | 'filters'>,
|
||||
filter: Record<string, unknown> | undefined,
|
||||
rows: Email[],
|
||||
accountId: string | undefined,
|
||||
): void {
|
||||
if (!filter || rows.length === 0 || !filterHasSnippetTerms(filter)) return;
|
||||
const { activeAccountId, currentMailboxId, searchQuery, filters } = useEmailStore.getState();
|
||||
// The page was asked for another search than the one now on screen.
|
||||
if (searchQuery !== view.searchQuery || filters !== view.filters) return;
|
||||
const groups = new Map<string, { accountId?: string; stamp?: string; emailIds: string[] }>();
|
||||
for (const e of rows) {
|
||||
const key = e.jmapAccountId ?? '';
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = { accountId: e.jmapAccountId ?? accountId, stamp: e.jmapAccountId, emailIds: [] };
|
||||
groups.set(key, group);
|
||||
}
|
||||
group.emailIds.push(e.id);
|
||||
}
|
||||
const list = [...groups.values()];
|
||||
void getSearchSnippets(filter, list).then((results) => {
|
||||
const state = useEmailStore.getState();
|
||||
if (
|
||||
state.activeAccountId !== activeAccountId || state.currentMailboxId !== currentMailboxId ||
|
||||
state.searchQuery !== searchQuery || state.filters !== filters ||
|
||||
!jmapClientServesActiveAccount(activeAccountId)
|
||||
) return;
|
||||
const batches: SnippetBatch[] = [];
|
||||
list.forEach((group, i) => {
|
||||
const found = results[i];
|
||||
if (found) batches.push({ accountId: group.stamp, list: found });
|
||||
});
|
||||
const emails = withSearchSnippets(state.emails, batches);
|
||||
if (emails !== state.emails) useEmailStore.setState({ emails });
|
||||
}).catch(() => { /* the rows keep their plain subject and preview */ });
|
||||
}
|
||||
|
||||
/**
|
||||
* The total that drives load-more. RFC 8620 makes Email/query's `total`
|
||||
* optional and some servers leave it out (#1364); the API layer reads a
|
||||
@@ -1256,9 +1308,9 @@ export const useEmailStore = create<EmailState>()(
|
||||
now.searchQuery !== searchQuery || now.filters !== filters
|
||||
) return;
|
||||
const shown = new Set(now.emails.map(rowKeyOf));
|
||||
const added = page.list.filter((e) => !shown.has(rowKeyOf(e)));
|
||||
set({
|
||||
emails: [...now.emails, ...page.list.filter((e) => !shown.has(rowKeyOf(e)))]
|
||||
.sort(listComparator(now)),
|
||||
emails: [...now.emails, ...added].sort(listComparator(now)),
|
||||
// An account that failed this time keeps counting what it showed,
|
||||
// so load-more doesn't keep asking for it.
|
||||
totalEmails: page.total + Object.keys(page.errors).reduce((n, id) => n + (loaded[id] ?? 0), 0),
|
||||
@@ -1266,6 +1318,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
accountErrors: page.errors,
|
||||
loading: false,
|
||||
});
|
||||
highlightSearchHits({ searchQuery, filters }, filter, added, undefined);
|
||||
return;
|
||||
}
|
||||
const { list, total: serverTotal, ids, threads } = await queryEmailPage(scope.mailboxId, {
|
||||
@@ -1303,6 +1356,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
};
|
||||
}
|
||||
set(updates);
|
||||
highlightSearchHits({ searchQuery, filters }, filter, newEmails, scope.accountId);
|
||||
} catch (err) {
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load more' });
|
||||
@@ -3153,6 +3207,7 @@ async function refreshEmailsImpl(): Promise<void> {
|
||||
accountErrors: page.errors,
|
||||
loading: false,
|
||||
});
|
||||
highlightSearchHits({ searchQuery, filters }, filter, page.list, undefined);
|
||||
return;
|
||||
}
|
||||
const threads = !useSettingsStore.getState().disableThreading;
|
||||
@@ -3197,6 +3252,7 @@ async function refreshEmailsImpl(): Promise<void> {
|
||||
};
|
||||
}
|
||||
set(updates);
|
||||
if (!baseView) highlightSearchHits({ searchQuery, filters }, filter, queryRes.list, scope.accountId);
|
||||
} catch (err) {
|
||||
console.warn('[email-store] refreshEmails failed:', err);
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
|
||||
Reference in New Issue
Block a user