|
|
|
@@ -207,7 +207,7 @@ function normalizedMessageFlags(value) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findMessageRow(folder, uid) {
|
|
|
|
|
return $$(".message-row").find((row) => row.dataset.folder === String(folder) && Number(row.dataset.uid) === Number(uid)) || null;
|
|
|
|
|
return $$(".message-row").find((row) => rowRepresentsMessage(row, folder, uid)) || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateMessageRowState(folder, uid, flags) {
|
|
|
|
@@ -239,7 +239,11 @@ function applyLocalMessageFlags(folder, uid, flags) {
|
|
|
|
|
state.currentDetail.flags = [...normalized];
|
|
|
|
|
$("#flag-btn")?.classList.toggle("active", normalized.includes("\\Flagged"));
|
|
|
|
|
}
|
|
|
|
|
updateMessageRowState(folder, uid, normalized);
|
|
|
|
|
if (state.mailPrefs.thread_mode === "flat") updateMessageRowState(folder, uid, normalized);
|
|
|
|
|
else {
|
|
|
|
|
renderMessages();
|
|
|
|
|
void applySnoozeHiding();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyOptimisticMessageFlag(folder, uid, flag, add) {
|
|
|
|
@@ -281,7 +285,7 @@ function updateActiveMessageRow() {
|
|
|
|
|
for (const row of $$(".message-row")) {
|
|
|
|
|
row.classList.toggle(
|
|
|
|
|
"active",
|
|
|
|
|
Boolean(state.selected) && row.dataset.folder === state.selected.folder && Number(row.dataset.uid) === Number(state.selected.uid),
|
|
|
|
|
Boolean(state.selected) && rowRepresentsMessage(row, state.selected.folder, state.selected.uid),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -321,11 +325,128 @@ async function applySnoozeHiding() {
|
|
|
|
|
const sleeping = new Set((data.uids || []).map(String));
|
|
|
|
|
if (!sleeping.size || folder !== state.folder) return;
|
|
|
|
|
for (const row of $$(".message-row", $("#message-list"))) {
|
|
|
|
|
if (sleeping.has(row.dataset.uid)) row.style.display = "none";
|
|
|
|
|
if (row.dataset.threadCollapsed === "true") {
|
|
|
|
|
const members = rowThreadMembers(row).filter(([memberFolder]) => memberFolder === folder);
|
|
|
|
|
if (members.length && members.every(([, memberUid]) => sleeping.has(String(memberUid)))) row.style.display = "none";
|
|
|
|
|
} else if (sleeping.has(row.dataset.uid)) {
|
|
|
|
|
row.style.display = "none";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (_) { /* best-effort visual filter only; never blocks the folder view */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function threadHeaderIds(value) {
|
|
|
|
|
const text = String(value || "").trim();
|
|
|
|
|
if (!text) return [];
|
|
|
|
|
const bracketed = text.match(/<[^>]+>/g);
|
|
|
|
|
const raw = bracketed?.length ? bracketed : text.split(/\s+/);
|
|
|
|
|
return [...new Set(raw.map((item) => String(item || "").trim()).filter(Boolean))];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function messageThreadTokens(message) {
|
|
|
|
|
return [...new Set([
|
|
|
|
|
...threadHeaderIds(message?.message_id),
|
|
|
|
|
...threadHeaderIds(message?.in_reply_to),
|
|
|
|
|
...threadHeaderIds(message?.references),
|
|
|
|
|
])];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function threadGroupKey(messages) {
|
|
|
|
|
const roots = messages
|
|
|
|
|
.filter((message) => threadHeaderIds(message?.in_reply_to).length === 0 && threadHeaderIds(message?.references).length === 0)
|
|
|
|
|
.flatMap((message) => threadHeaderIds(message?.message_id));
|
|
|
|
|
if (roots.length) return `thread:${roots.sort()[0]}`;
|
|
|
|
|
|
|
|
|
|
const candidates = new Map();
|
|
|
|
|
for (const message of messages) {
|
|
|
|
|
const refs = threadHeaderIds(message?.references);
|
|
|
|
|
const parent = refs[0] || threadHeaderIds(message?.in_reply_to)[0];
|
|
|
|
|
if (parent) candidates.set(parent, (candidates.get(parent) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
const preferred = [...candidates.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
|
|
|
|
|
if (preferred) return `thread:${preferred}`;
|
|
|
|
|
return `thread:${messages.map(messageKey).sort()[0] || "unknown"}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildMessageThreadGroups(messages) {
|
|
|
|
|
const parent = messages.map((_, index) => index);
|
|
|
|
|
const find = (index) => {
|
|
|
|
|
let root = index;
|
|
|
|
|
while (parent[root] !== root) root = parent[root];
|
|
|
|
|
while (parent[index] !== index) {
|
|
|
|
|
const next = parent[index];
|
|
|
|
|
parent[index] = root;
|
|
|
|
|
index = next;
|
|
|
|
|
}
|
|
|
|
|
return root;
|
|
|
|
|
};
|
|
|
|
|
const union = (left, right) => {
|
|
|
|
|
const a = find(left);
|
|
|
|
|
const b = find(right);
|
|
|
|
|
if (a !== b) parent[b] = a;
|
|
|
|
|
};
|
|
|
|
|
const firstByToken = new Map();
|
|
|
|
|
messages.forEach((message, index) => {
|
|
|
|
|
for (const token of messageThreadTokens(message)) {
|
|
|
|
|
const existing = firstByToken.get(token);
|
|
|
|
|
if (existing === undefined) firstByToken.set(token, index);
|
|
|
|
|
else union(index, existing);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const grouped = new Map();
|
|
|
|
|
messages.forEach((message, index) => {
|
|
|
|
|
const root = find(index);
|
|
|
|
|
if (!grouped.has(root)) grouped.set(root, []);
|
|
|
|
|
grouped.get(root).push({ message, index });
|
|
|
|
|
});
|
|
|
|
|
return [...grouped.values()]
|
|
|
|
|
.map((items) => ({
|
|
|
|
|
members: items.sort((a, b) => a.index - b.index).map((item) => item.message),
|
|
|
|
|
firstIndex: Math.min(...items.map((item) => item.index)),
|
|
|
|
|
}))
|
|
|
|
|
.sort((a, b) => a.firstIndex - b.firstIndex);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mailboxThreadRows() {
|
|
|
|
|
if (state.mailPrefs.thread_mode === "flat") {
|
|
|
|
|
return state.messages.map((message) => ({ message, members: [message], threadHead: false, threadChild: false, expanded: false, threadKey: null }));
|
|
|
|
|
}
|
|
|
|
|
const rows = [];
|
|
|
|
|
for (const group of buildMessageThreadGroups(state.messages)) {
|
|
|
|
|
if (group.members.length <= 1) {
|
|
|
|
|
rows.push({ message: group.members[0], members: group.members, threadHead: false, threadChild: false, expanded: false, threadKey: null });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const threadKey = threadGroupKey(group.members);
|
|
|
|
|
const expanded = state.selectionMode || state.threadListExpanded.has(threadKey);
|
|
|
|
|
group.members.forEach((message, index) => {
|
|
|
|
|
if (index === 0 || expanded) {
|
|
|
|
|
rows.push({ message, members: group.members, threadHead: index === 0, threadChild: index > 0, expanded, threadKey });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return rows;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rowThreadMembers(row) {
|
|
|
|
|
if (!row?.dataset.threadMembers) return [];
|
|
|
|
|
try {
|
|
|
|
|
const members = JSON.parse(row.dataset.threadMembers);
|
|
|
|
|
return Array.isArray(members) ? members : [];
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rowRepresentsMessage(row, folder, uid) {
|
|
|
|
|
if (!row) return false;
|
|
|
|
|
if (row.dataset.folder === String(folder) && Number(row.dataset.uid) === Number(uid)) return true;
|
|
|
|
|
if (row.dataset.threadCollapsed !== "true") return false;
|
|
|
|
|
return rowThreadMembers(row).some(([memberFolder, memberUid]) => String(memberFolder) === String(folder) && Number(memberUid) === Number(uid));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderMessages() {
|
|
|
|
|
$("#folder-title").textContent = state.folder;
|
|
|
|
|
const folderTotal = Number(state.folders.find((folder) => folder.name === state.folder)?.total_count);
|
|
|
|
@@ -336,19 +457,33 @@ function renderMessages() {
|
|
|
|
|
list.classList.toggle("selection-mode", state.selectionMode);
|
|
|
|
|
list.replaceChildren();
|
|
|
|
|
if (!state.messages.length) list.innerHTML = `<div class="empty-list">${escapeHtml(trText(state.searchContext ? "No matching messages." : "No messages in this folder."))}</div>`;
|
|
|
|
|
for (const message of state.messages) {
|
|
|
|
|
updateThreadModeButton(state.currentDetail);
|
|
|
|
|
for (const viewItem of mailboxThreadRows()) {
|
|
|
|
|
const message = viewItem.message;
|
|
|
|
|
const folder = message.folder || state.folder;
|
|
|
|
|
const flags = Array.isArray(message.flags) ? message.flags.join(" ") : (message.flags || "");
|
|
|
|
|
const unread = !flags.includes("\\Seen");
|
|
|
|
|
const flagged = flags.includes("\\Flagged");
|
|
|
|
|
const collapsedThreadHead = viewItem.threadHead && !viewItem.expanded && viewItem.members.length > 1;
|
|
|
|
|
const unread = collapsedThreadHead
|
|
|
|
|
? viewItem.members.some((member) => !normalizedMessageFlags(member.flags).includes("\\Seen"))
|
|
|
|
|
: !flags.includes("\\Seen");
|
|
|
|
|
const flagged = collapsedThreadHead
|
|
|
|
|
? viewItem.members.some((member) => normalizedMessageFlags(member.flags).includes("\\Flagged"))
|
|
|
|
|
: flags.includes("\\Flagged");
|
|
|
|
|
const checked = state.selectedMessages.has(messageKey(message));
|
|
|
|
|
const active = state.selected?.uid === message.uid && state.selected?.folder === folder;
|
|
|
|
|
const row = el("div", `message-row ${unread ? "unread" : ""} ${flagged ? "flagged" : ""} ${active ? "active" : ""} ${checked ? "selected" : ""}`);
|
|
|
|
|
const active = collapsedThreadHead
|
|
|
|
|
? viewItem.members.some((member) => state.selected?.uid === member.uid && state.selected?.folder === (member.folder || state.folder))
|
|
|
|
|
: state.selected?.uid === message.uid && state.selected?.folder === folder;
|
|
|
|
|
const row = el("div", `message-row ${unread ? "unread" : ""} ${flagged ? "flagged" : ""} ${active ? "active" : ""} ${checked ? "selected" : ""} ${viewItem.threadHead ? "thread-head" : ""} ${viewItem.threadChild ? "thread-child" : ""}`);
|
|
|
|
|
row.dataset.folder = folder;
|
|
|
|
|
row.dataset.uid = String(message.uid);
|
|
|
|
|
if (viewItem.members.length > 1) {
|
|
|
|
|
row.dataset.threadMembers = JSON.stringify(viewItem.members.map((member) => [member.folder || state.folder, member.uid]));
|
|
|
|
|
row.dataset.threadCollapsed = String(collapsedThreadHead);
|
|
|
|
|
row.dataset.threadKey = viewItem.threadKey || "";
|
|
|
|
|
}
|
|
|
|
|
row.tabIndex = 0;
|
|
|
|
|
row.setAttribute("role", "button");
|
|
|
|
|
row.setAttribute("aria-label", `${message.from_name || message.from_address || "Unknown"}: ${message.subject || "(no subject)"}`);
|
|
|
|
|
row.setAttribute("aria-label", `${message.from_name || message.from_address || "Unknown"}: ${message.subject || "(no subject)"}${viewItem.threadHead && viewItem.members.length > 1 ? ` (${viewItem.members.length})` : ""}`);
|
|
|
|
|
const checkbox = document.createElement("input");
|
|
|
|
|
checkbox.type = "checkbox";
|
|
|
|
|
checkbox.className = "message-select";
|
|
|
|
@@ -369,6 +504,23 @@ function renderMessages() {
|
|
|
|
|
const subjectNode = row.querySelector(".message-subject");
|
|
|
|
|
renderHighlightedText(subjectNode, subjectText, highlightTerms);
|
|
|
|
|
subjectNode.title = subjectText;
|
|
|
|
|
if (viewItem.threadHead && viewItem.members.length > 1) {
|
|
|
|
|
const toggle = el("button", `thread-list-toggle ${viewItem.expanded ? "expanded" : ""}`);
|
|
|
|
|
toggle.type = "button";
|
|
|
|
|
toggle.title = trText("Toggle thread");
|
|
|
|
|
toggle.setAttribute("aria-label", toggle.title);
|
|
|
|
|
toggle.setAttribute("aria-expanded", String(viewItem.expanded));
|
|
|
|
|
toggle.innerHTML = svgIcon("chevron-right");
|
|
|
|
|
toggle.addEventListener("click", (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
if (state.threadListExpanded.has(viewItem.threadKey)) state.threadListExpanded.delete(viewItem.threadKey);
|
|
|
|
|
else state.threadListExpanded.add(viewItem.threadKey);
|
|
|
|
|
renderMessages();
|
|
|
|
|
void applySnoozeHiding();
|
|
|
|
|
});
|
|
|
|
|
subjectNode.before(toggle);
|
|
|
|
|
subjectNode.append(el("span", "thread-count", ` (${viewItem.members.length})`));
|
|
|
|
|
}
|
|
|
|
|
renderHighlightedText(row.querySelector(".message-snippet"), state.mailPrefs.show_snippets !== false ? (message.snippet || "") : "", highlightTerms);
|
|
|
|
|
const icons = row.querySelector(".message-icons");
|
|
|
|
|
if (message.has_attachments) {
|
|
|
|
@@ -458,12 +610,17 @@ function renderMessages() {
|
|
|
|
|
|
|
|
|
|
async function navigateMessage(delta) {
|
|
|
|
|
if (!state.selected) return;
|
|
|
|
|
const index = state.messages.findIndex((message) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder);
|
|
|
|
|
const rows = mailboxThreadRows();
|
|
|
|
|
let index = rows.findIndex(({ message }) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder);
|
|
|
|
|
if (index < 0) {
|
|
|
|
|
index = rows.findIndex((item) => item.threadHead && !item.expanded && item.members.some((message) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder));
|
|
|
|
|
}
|
|
|
|
|
if (index < 0) return;
|
|
|
|
|
const next = state.messages[index + delta];
|
|
|
|
|
const next = rows[index + delta]?.message;
|
|
|
|
|
if (next) return openMessage(next.folder || state.folder, next.uid);
|
|
|
|
|
if (!state.searchContext && mailboxLoadingMode() === "pagination" && await changeMailboxPage(delta > 0 ? 1 : -1)) {
|
|
|
|
|
const boundary = delta > 0 ? state.messages[0] : state.messages[state.messages.length - 1];
|
|
|
|
|
const nextRows = mailboxThreadRows();
|
|
|
|
|
const boundary = delta > 0 ? nextRows[0]?.message : nextRows[nextRows.length - 1]?.message;
|
|
|
|
|
if (boundary) return openMessage(boundary.folder || state.folder, boundary.uid);
|
|
|
|
|
}
|
|
|
|
|
toast(delta > 0 ? "No next message" : "No previous message");
|
|
|
|
@@ -668,36 +825,44 @@ function scheduleMarkRead(detail) {
|
|
|
|
|
if (delay === 0) run(); else state.markReadTimer = setTimeout(run, delay);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateThreadModeButton(detail) {
|
|
|
|
|
function updateThreadModeButton(_detail = null) {
|
|
|
|
|
const button = $("#thread-mode-btn");
|
|
|
|
|
const label = $("#thread-mode-label");
|
|
|
|
|
if (!button || !label) return;
|
|
|
|
|
const hasThread = (detail?.thread || []).length > 1;
|
|
|
|
|
if (!button) return;
|
|
|
|
|
const threaded = state.mailPrefs.thread_mode !== "flat";
|
|
|
|
|
button.classList.toggle("hidden", !hasThread);
|
|
|
|
|
button.setAttribute("aria-pressed", String(threaded));
|
|
|
|
|
const action = threaded ? "Show single message" : "Show thread";
|
|
|
|
|
const action = threaded ? "Show messages" : "Show threads";
|
|
|
|
|
button.title = trText(action);
|
|
|
|
|
button.setAttribute("aria-label", trText(action));
|
|
|
|
|
label.textContent = trText(threaded ? "Single message" : "Thread");
|
|
|
|
|
const use = button.querySelector("use");
|
|
|
|
|
if (use) {
|
|
|
|
|
use.setAttribute(
|
|
|
|
|
"href",
|
|
|
|
|
`${ICONS}#${threaded ? "icon-list-ordered" : "icon-list"}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function toggleReaderThreadMode() {
|
|
|
|
|
if (!state.currentDetail || (state.currentDetail.thread || []).length <= 1) return;
|
|
|
|
|
const previous = state.mailPrefs.thread_mode || "threaded";
|
|
|
|
|
const next = previous === "flat" ? "threaded" : "flat";
|
|
|
|
|
state.mailPrefs.thread_mode = next;
|
|
|
|
|
const select = $("#mail-pref-thread-mode");
|
|
|
|
|
if (select) select.value = next;
|
|
|
|
|
renderThread(state.currentDetail);
|
|
|
|
|
renderMessages();
|
|
|
|
|
if (state.currentDetail) renderThread(state.currentDetail);
|
|
|
|
|
void applySnoozeHiding();
|
|
|
|
|
try {
|
|
|
|
|
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify({ thread_mode: next }) });
|
|
|
|
|
if (select) select.value = state.mailPrefs.thread_mode || next;
|
|
|
|
|
renderThread(state.currentDetail);
|
|
|
|
|
renderMessages();
|
|
|
|
|
if (state.currentDetail) renderThread(state.currentDetail);
|
|
|
|
|
void applySnoozeHiding();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
state.mailPrefs.thread_mode = previous;
|
|
|
|
|
if (select) select.value = previous;
|
|
|
|
|
renderThread(state.currentDetail);
|
|
|
|
|
renderMessages();
|
|
|
|
|
if (state.currentDetail) renderThread(state.currentDetail);
|
|
|
|
|
void applySnoozeHiding();
|
|
|
|
|
toast(error.message, "error");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|