Files
native/src/lib/thread-utils.ts
T
Linus Rath ee50418bac fix: search the shared accounts' folders too with "All folders" #1082
"All folders" sent its Email/query only to the account of the open
folder, so mail in a shared (group) account the same login reaches never
showed up: "zephyr" found the 3 own messages and missed the 5 in the team
inbox, although that inbox sits right there in the sidebar.

The search now asks the own account and every shared account with
folders in the sidebar, like the webmail's `searchAcrossAccounts`:

- one request carries Email/query, Email/get and Thread/get for each
  account (`queryEmailPagesAcrossAccounts`), and the pages are merged
  under the list order
- each row is stamped with its JMAP account (`Email.jmapAccountId`), so
  opening a hit opens it in that account and read, star, tag, archive,
  delete, spam and move act there, filing into that account's own
  Archive, Trash or Junk; one undo restores rows in every account
- "load more" continues each account from its own rows on screen rather
  than one shared offset, which would skip messages of the account with
  more hits
- an account that fails leaves the others' hits on screen with a notice
- a push for any account re-runs the search
- list keys, thread grouping and conversation sizes are scoped by
  account, as JMAP ids are only unique per account
- deleting a hit judges Trash and Junk by the hit's own folders, not the
  open folder, both for the move and for the permanent-delete confirm

A search scoped to one folder still asks only that folder's account.
2026-09-24 20:05:38 +02:00

154 lines
5.3 KiB
TypeScript

import type { Email } from '../api/types';
// Port of the webmail's `lib/thread-utils.ts` (tag id extraction, thread
// grouping helpers) trimmed to what the native list needs.
/** Active prefix for new keyword tags written to JMAP. */
export const KEYWORD_PREFIX = '$label:';
/** Legacy prefix still recognised when reading. */
export const KEYWORD_PREFIX_LEGACY = '$color:';
/**
* Every tag id set on a message. Reads both the current `$label:` prefix and
* the legacy `$color:` prefix; a tag written under both spellings is one tag.
*/
export function getEmailTagIds(keywords: Record<string, boolean> | undefined): string[] {
if (!keywords) return [];
const tags = new Set<string>();
for (const key of Object.keys(keywords)) {
if (keywords[key] !== true) continue;
if (key.startsWith(KEYWORD_PREFIX)) tags.add(key.slice(KEYWORD_PREFIX.length));
else if (key.startsWith(KEYWORD_PREFIX_LEGACY)) tags.add(key.slice(KEYWORD_PREFIX_LEGACY.length));
}
return [...tags];
}
/**
* Every tag anywhere in a thread, deduplicated. A collapsed thread row stands
* in for all its messages, so it has to account for all their tags.
*/
export function getThreadTagIds(emails: Email[]): string[] {
const tags = new Set<string>();
for (const email of emails) {
for (const tag of getEmailTagIds(email.keywords)) tags.add(tag);
}
return [...tags];
}
/** The tag id inside a keyword, or null when the keyword is not a tag. */
export function tagIdFromKeyword(keyword: string): string | null {
for (const prefix of [KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY]) {
if (keyword.startsWith(prefix)) {
const id = keyword.slice(prefix.length);
return id.length > 0 ? id : null;
}
}
return null;
}
/**
* A message or thread id scoped by the account stamp of a row from a list
* that spans accounts: JMAP ids are only unique per account (Stalwart hands
* out small per-account counters), so keys built from bare ids would merge
* two accounts' rows. Unstamped rows keep the bare id (webmail
* `threadKeyFor`).
*/
export function accountScopedId(email: Pick<Email, 'jmapAccountId'>, id: string): string {
return email.jmapAccountId ? `${email.jmapAccountId}:${id}` : id;
}
/** Key a message groups under: its thread, or itself when threading is off. */
export function threadKeyOf(email: Email, disableThreading: boolean): string {
return accountScopedId(email, disableThreading ? email.id : email.threadId || email.id);
}
function time(value: string | undefined): number {
if (!value) return 0;
const t = new Date(value).getTime();
return Number.isNaN(t) ? 0 : t;
}
export function isPinned(email: Email): boolean {
return !!email.keywords?.$pinned;
}
/**
* Collapses same-thread messages to one row each. Each thread's row is its
* *newest* message (by receivedAt) regardless of the list's sort direction,
* and takes the position of whichever of its messages the list showed first —
* RFC 8621 §4.4.3 thread-collapsing semantics, the same rule the webmail's
* `sortThreadGroups` applies. Rows whose thread carries a `$pinned` message
* stay on top, mirroring the pinned-first server sort.
*/
export function collapseThreads(
emails: Email[],
disableThreading: boolean,
opts: { pinnedFirst?: boolean } = {},
): Email[] {
const pinnedFirst = opts.pinnedFirst !== false;
const groups = new Map<string, Email[]>();
for (const e of emails) {
const key = threadKeyOf(e, disableThreading);
const list = groups.get(key);
if (list) list.push(e);
else groups.set(key, [e]);
}
const rows: Array<{ email: Email; pinned: boolean; order: number }> = [];
let order = 0;
for (const list of groups.values()) {
let newest = list[0];
for (const e of list) {
if (time(e.receivedAt) > time(newest.receivedAt)) newest = e;
}
rows.push({ email: newest, pinned: pinnedFirst && list.some(isPinned), order: order++ });
}
if (pinnedFirst) {
rows.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || a.order - b.order);
}
return rows.map((r) => r.email);
}
/** Every loaded message of each thread, keyed by thread key. */
export function groupByThread(emails: Email[], disableThreading: boolean): Map<string, Email[]> {
const groups = new Map<string, Email[]>();
for (const e of emails) {
const key = threadKeyOf(e, disableThreading);
const list = groups.get(key);
if (list) list.push(e);
else groups.set(key, [e]);
}
return groups;
}
/**
* Expands selected representative ids to every loaded message of their
* threads, so batch actions on "3 selected" conversations touch all their
* messages rather than one each. Ids that are not in `emails` pass through.
*/
export function expandThreadSelection(
ids: Iterable<string>,
emails: Email[],
disableThreading: boolean,
): string[] {
const wanted = new Set(ids);
if (disableThreading) return [...wanted];
const byId = new Map(emails.map((e) => [e.id, e]));
const keys = new Set<string>();
for (const id of wanted) {
const e = byId.get(id);
if (e) keys.add(threadKeyOf(e, false));
}
const out: string[] = [];
const seen = new Set<string>();
for (const e of emails) {
if (keys.has(threadKeyOf(e, false)) && !seen.has(e.id)) {
seen.add(e.id);
out.push(e.id);
}
}
for (const id of wanted) {
if (!seen.has(id)) { seen.add(id); out.push(id); }
}
return out;
}