mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
An "All folders" search or a tag view holds rows of the own and the shared accounts, and Stalwart hands out ids per account, so an own and a team message can share an id. The list still named its rows by bare id: selecting one of such a pair selected both, a batch action hit both, and a swipe or single-row action took whichever came first, in the other account. The list now names every row by `rowKeyOf` (its id, scoped by its account in a list spanning accounts; the bare id in a folder, so nothing changes there) end to end: - FlatList keys, row presses, swipes, selection and select-all - thread expansion of a selection (`expandThreadSelection`) - every store action the list calls: read, unread, star, pin, the per-account keyword batch, move, archive, delete and spam, and the rows they drop or keep in view - the viewer's pages, which now come only from the rows of the opened message's account (`listRowsOfAccount`), and its mark-read, which goes through the store when the list holds that account's row A key whose row is no longer loaded is ignored rather than guessed onto the open folder's account. The unified inbox already keys its rows by account and needed no change.
165 lines
5.7 KiB
TypeScript
165 lines
5.7 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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|