mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
143 lines
4.8 KiB
TypeScript
143 lines
4.8 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;
|
|
}
|
|
|
|
/** Key a message groups under: its thread, or itself when threading is off. */
|
|
export function threadKeyOf(email: Email, disableThreading: boolean): string {
|
|
return 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;
|
|
}
|