diff --git a/CHANGELOG.md b/CHANGELOG.md index 128a96d..61463f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ What changed in each RustBox release, written for the people who use and run it. ## 0.2.28 - Replying to a message you sent no longer addresses the reply back to yourself; RustBox chooses the other thread participant(s), including when aliases/identities are used. -- The reader now has a direct toggle between thread view and a single-message view; the choice uses the existing mailbox thread preference. +- The mailbox toolbar now switches the message list between threaded and flat views. The compact icon-only control matches the message-selection button and changes its icon to reflect the active view. Threaded mode groups related messages by Message-ID/In-Reply-To/References, shows the thread size, and lets each thread expand or collapse; the reader follows the same persisted mailbox preference. - Polish text decoding is more tolerant of legacy mail: RFC 2047 ISO-8859-2 subjects and common UTF-8/Windows-1252 or ISO-8859-2/Western-Latin mojibake are repaired in headers, bodies and cached message previews. ## 0.2.27 diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index 0be2795..fff964c 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -2,7 +2,7 @@ RustBox 0.2.28 release manifest 0.2.28 release changes: - replies to own sent messages target the other participant(s), with account aliases/identities treated as own addresses -- reader toolbar toggles between threaded and single-message presentation using the persisted mailbox `thread_mode` preference +- mailbox toolbar toggles between grouped threads and individual messages using the persisted mailbox `thread_mode` preference; thread rows expand/collapse and use Message-ID/In-Reply-To/References metadata - legacy Polish text repair covers common UTF-8/Windows-1252 and ISO-8859-2/Western-Latin mojibake in headers, bodies and cached summaries/details; exact ISO-8859-2 folded-subject coverage added 0.2.27 release changes: diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index a23e12a..aa4bb21 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,7 +1,7 @@ RustBox 0.2.28 - final source validation 0.2.28 scope: -- correct reply recipients for own sent messages in threads, reader thread/single-message toggle, and stronger Polish legacy-encoding/mojibake repair, plus the 0.2.27 mail-list and appearance baseline +- correct reply recipients for own sent messages in threads, Roundcube-style mailbox thread/flat list toggle (compact icon-button matching Select messages, with an icon reflecting the active mode), and stronger Polish legacy-encoding/mojibake repair, plus the 0.2.27 mail-list and appearance baseline 0.2.25 scope: - filters: apply now on existing messages (`POST /api/filters/match`, IMAP UID SEARCH bounds + cache), exceptions, new conditions and actions, templates, missing-folder creation, "always move from this sender" @@ -72,6 +72,7 @@ RustBox 0.2.28 - final source validation Completed in this environment: - offline release validator: scripts/validate-release-static.py (458/458 checks passed) - browser JavaScript assembled in manifest order passes `node --check` +- mailbox thread grouping smoke test covers a three-message References chain, expand/collapse behavior and flat-mode fallback - reply-recipient helper smoke test covers own-account, alias and normal incoming-message replies - supplied legacy Polish `.eml` decodes to the expected ISO-8859-2 subject and UTF-8 quoted-printable body in an independent MIME parser check - all JSON files parse successfully diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 322511d..1d92f75 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -6,7 +6,7 @@ This file tracks the `0.2.28` source snapshot. Storage/search scope and outstand ## 0.2.28 thread replies and legacy Polish mail decoding - Reply composition detects all configured account/identity addresses; replying to an own sent message selects the first non-own recipient and Reply all keeps the remaining external participants. -- The reader toolbar toggles the existing `thread_mode` mailbox preference between threaded and flat/single-message presentation. +- The mailbox toolbar toggles the existing `thread_mode` preference between a grouped thread list and a flat message list. Thread groups use Message-ID/In-Reply-To/References metadata, can be expanded/collapsed, and the reader follows the same preference. - Header/body rendering repairs common Polish mojibake while retaining RFC 2047 charset decoding; cached summaries/details are normalized on output so old cached rows benefit without a schema migration. ## 0.2.27 mail-list and appearance polish diff --git a/scripts/validate-release-static.py b/scripts/validate-release-static.py index 3d694cb..fec7f35 100755 --- a/scripts/validate-release-static.py +++ b/scripts/validate-release-static.py @@ -207,7 +207,7 @@ require('mail_parser::MessageParser::default().parse(synthetic.as_bytes())' in r require('encoding_rs::Encoding::for_label' in read("src/imap/parse.rs") and 'decodes_polish_rfc2047_subjects' in read("src/imap/parse.rs"), "Polish UTF-8/ISO-8859-2/Windows-1250 header decoding is covered") require('repair_common_mojibake' in read("src/imap/parse.rs") and 'Ubezpieczenie_nieruchomo=B6ci' in read("src/imap/parse.rs") and 'objętość 12 m³' in read("src/imap/parse.rs"), "legacy Polish mojibake repair covers the supplied ISO-8859-2 pattern without rewriting normal m³ text") require('function replyRecipientsForMessage(detail)' in compose_js and '...(state.identities || []).map((identity) => identity.email)' in compose_js and 'const replyRecipients = replyRecipientsForMessage(detail);' in compose_js, "replying to an own sent message can target non-own thread participants and recognizes identities") -require('id="thread-mode-btn"' in index_html and 'function toggleReaderThreadMode()' in reader_js and 'thread_mode: next' in reader_js and '$(("#thread-mode-btn"))' not in bindings_js and '$("#thread-mode-btn").addEventListener("click", toggleReaderThreadMode);' in bindings_js, "reader exposes a persisted threaded/single-message toggle") +require('id="thread-mode-btn"' in index_html and 'function toggleReaderThreadMode()' in reader_js and 'function buildMessageThreadGroups(messages)' in reader_js and 'threadListExpanded' in bootstrap_js and 'thread_mode: next' in reader_js and '$("#thread-mode-btn").addEventListener("click", toggleReaderThreadMode);' in bindings_js, "mailbox exposes a persisted threaded/flat list toggle with expandable thread groups") require('let is_pdf = response_content_type_is(&response, "application/pdf");' in read("src/routes/mod.rs") and 'HeaderValue::from_static("SAMEORIGIN")' in read("src/routes/mod.rs") and "HeaderValue::from_static(\"frame-ancestors 'self'\")" in read("src/routes/mod.rs"), "security headers allow same-origin framing for inline PDF previews only") index_html = read("src/web/index.html") require('class="compose-limit-summary"' in index_html and 'Per-file upload limit set with' not in index_html and 'Hard limit for the complete RFC822/MIME message' not in index_html, "Composing shows compact raw size limits without implementation details") diff --git a/src/routes/messages/mod.rs b/src/routes/messages/mod.rs index c9503ba..440ef0a 100644 --- a/src/routes/messages/mod.rs +++ b/src/routes/messages/mod.rs @@ -404,6 +404,9 @@ pub async fn list_messages( MessageSummary { uid: m.uid, folder: m.folder, + message_id: m.message_id, + in_reply_to: m.in_reply_to, + references: m.references_header, subject: repair_common_mojibake(&m.subject), from_address: m.from_address, from_name: repair_common_mojibake(&m.from_name), diff --git a/src/routes/messages/types.rs b/src/routes/messages/types.rs index f1744ec..d334a98 100644 --- a/src/routes/messages/types.rs +++ b/src/routes/messages/types.rs @@ -68,6 +68,9 @@ pub(crate) struct ListMessagesResponse { pub struct MessageSummary { pub uid: u32, pub folder: String, + pub message_id: Option, + pub in_reply_to: Option, + pub references: Option, pub subject: String, pub from_address: String, pub from_name: String, diff --git a/src/routes/search.rs b/src/routes/search.rs index 9254ee9..5ebbbab 100644 --- a/src/routes/search.rs +++ b/src/routes/search.rs @@ -252,6 +252,9 @@ pub struct SearchResponse { pub struct SearchResultItem { pub uid: u32, pub folder: String, + pub message_id: Option, + pub in_reply_to: Option, + pub references: Option, pub score: f32, pub subject: String, pub from_address: String, @@ -346,6 +349,9 @@ pub async fn search_messages( SearchResultItem { uid: msg.uid, folder: msg.folder, + message_id: msg.message_id, + in_reply_to: msg.in_reply_to, + references: msg.references_header, score: 1.0, subject: msg.subject, from_address: msg.from_address, @@ -414,6 +420,9 @@ pub async fn search_messages( results.push(SearchResultItem { uid: msg.uid, folder: msg.folder, + message_id: msg.message_id, + in_reply_to: msg.in_reply_to, + references: msg.references_header, score, subject: msg.subject, from_address: msg.from_address, diff --git a/src/routes/tags.rs b/src/routes/tags.rs index 54f0162..bdff253 100644 --- a/src/routes/tags.rs +++ b/src/routes/tags.rs @@ -82,6 +82,9 @@ fn build_summary(m: db::messages::CachedMessage, tags: Vec) -> Messa MessageSummary { uid: m.uid, folder: m.folder, + message_id: m.message_id, + in_reply_to: m.in_reply_to, + references: m.references_header, subject: m.subject, from_address: m.from_address, from_name: m.from_name, diff --git a/src/web/css/30-mail.css b/src/web/css/30-mail.css index fabc959..f6c58d0 100644 --- a/src/web/css/30-mail.css +++ b/src/web/css/30-mail.css @@ -1542,4 +1542,56 @@ html[data-mail-embed="true"] #reader-fullscreen { html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-icons { grid-column: 6; } -} \ No newline at end of file +} +/* Mailbox thread/list mode (Roundcube-style list grouping). */ +.thread-list-toggle { + flex: 0 0 auto; + display: inline-grid; + place-items: center; + width: 18px; + height: 18px; + margin: -2px 0 -2px -2px; + padding: 0; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--subtle); +} + +.thread-list-toggle:hover { + background: var(--hover); + color: var(--text); +} + +.thread-list-toggle svg { + width: 12px; + height: 12px; + transition: transform 120ms ease; +} + +.thread-list-toggle.expanded svg { + transform: rotate(90deg); +} + +.thread-count { + color: var(--subtle); + font-weight: 600; +} + +.message-row.thread-child .message-subject-line { + padding-left: 18px; + position: relative; +} + +.message-row.thread-child .message-subject-line::before { + content: "↳"; + position: absolute; + left: 2px; + color: var(--subtle); + font-size: 10px; +} + +html[data-reduce-motion="true"] .thread-list-toggle svg { + transition: none; +} + diff --git a/src/web/i18n/pl.json b/src/web/i18n/pl.json index 174fec4..1c70fcf 100644 --- a/src/web/i18n/pl.json +++ b/src/web/i18n/pl.json @@ -849,6 +849,8 @@ "Single message": "Pojedyncza wiadomość", "Show single message": "Pokaż pojedynczą wiadomość", "Show thread": "Pokaż wątek", + "Show messages": "Pokaż wiadomości osobno", + "Show threads": "Pokaż wątki", "Remote images are blocked.": "Zdalne obrazy są zablokowane.", "Load images": "Wczytaj obrazy", "Trust sender": "Zaufaj nadawcy", diff --git a/src/web/index.html b/src/web/index.html index 8295ce6..f6829fa 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -152,6 +152,10 @@ + - diff --git a/src/web/js/00-bootstrap.js b/src/web/js/00-bootstrap.js index e163e51..b183f5a 100644 --- a/src/web/js/00-bootstrap.js +++ b/src/web/js/00-bootstrap.js @@ -45,6 +45,7 @@ calendarCursor: new Date(), calendarView: localStorage.getItem("rustbox.calendarView") || "month", threadExpanded: localStorage.getItem("rustbox.threadExpanded") !== "false", + threadListExpanded: new Set(), calendarEvents: [], savedSearches: [], responseTemplates: [], diff --git a/src/web/js/40-messages-reader.js b/src/web/js/40-messages-reader.js index afeda33..a721087 100644 --- a/src/web/js/40-messages-reader.js +++ b/src/web/js/40-messages-reader.js @@ -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 = `
${escapeHtml(trText(state.searchContext ? "No matching messages." : "No messages in this folder."))}
`; - 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"); } }