import type { Email } from '../api/types'; import type { KeywordDef } from '../stores/keywords-store'; // 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 | undefined): string[] { if (!keywords) return []; const tags = new Set(); 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]; } /** * The tags to draw for a message: every tag id on it, with its local * definition where there is one. A tag no local definition explains (set by * another client or by the server) is kept, grey and labelled with its raw * id, so the header shows the same tags as the list row (#79). */ export function resolveEmailTags( keywords: Record | undefined, defs: readonly KeywordDef[], ): KeywordDef[] { return getEmailTagIds(keywords).map( (id) => defs.find((d) => d.id === id) ?? { id, label: id, color: 'gray' }, ); } /** * 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(); 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, id: string): string { return email.jmapAccountId ? `${email.jmapAccountId}:${id}` : id; } /** * A list row's identity: its id, scoped by its account in a list spanning * accounts, where an own and a shared message can share an id. What the list * keys, selects and hands to the store's actions (#1082). */ export function rowKeyOf(email: Pick): string { return accountScopedId(email, email.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(); 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 { const groups = new Map(); 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 rows (by `rowKeyOf`) to every loaded * message of their threads, so batch actions on "3 selected" conversations * touch all their messages rather than one each. Keys that are not in * `emails` pass through. */ export function expandThreadSelection( keys: Iterable, emails: Email[], disableThreading: boolean, ): string[] { const wanted = new Set(keys); if (disableThreading) return [...wanted]; const byKey = new Map(emails.map((e) => [rowKeyOf(e), e])); const threads = new Set(); for (const key of wanted) { const e = byKey.get(key); if (e) threads.add(threadKeyOf(e, false)); } const out: string[] = []; const seen = new Set(); for (const e of emails) { const key = rowKeyOf(e); if (threads.has(threadKeyOf(e, false)) && !seen.has(key)) { seen.add(key); out.push(key); } } for (const key of wanted) { if (!seen.has(key)) { seen.add(key); out.push(key); } } return out; } /** * Whether a conversation member can be replied to: not the user's own draft * and not a message waiting for its scheduled send (webmail 8b29254a6, * #1404). */ export function isReplyableMember( email: Pick, scheduledIds: ReadonlySet, ): boolean { return !email.keywords?.$draft && !scheduledIds.has(email.id); } /** * The message the quick reply answers: the newest member that can be * replied to, so it never quotes the user's own draft or scheduled reply. * Null when there is none. */ export function quickReplyTarget>( members: readonly T[], scheduledIds: ReadonlySet, ): T | null { for (let i = members.length - 1; i >= 0; i--) { if (isReplyableMember(members[i], scheduledIds)) return members[i]; } return null; }