Files
native/src/lib/thread-utils.ts
T

208 lines
7.1 KiB
TypeScript

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<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];
}
/**
* 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<string, boolean> | 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<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;
}
/**
* 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<Email, 'id' | 'jmapAccountId'>): 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<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 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<string>,
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<string>();
for (const key of wanted) {
const e = byKey.get(key);
if (e) threads.add(threadKeyOf(e, false));
}
const out: string[] = [];
const seen = new Set<string>();
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<Email, 'id' | 'keywords'>,
scheduledIds: ReadonlySet<string>,
): 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<T extends Pick<Email, 'id' | 'keywords'>>(
members: readonly T[],
scheduledIds: ReadonlySet<string>,
): T | null {
for (let i = members.length - 1; i >= 0; i--) {
if (isReplyableMember(members[i], scheduledIds)) return members[i];
}
return null;
}