v0.2.28_fix
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-06 13:08:12 +01:00
1 parent c160f25e4c
commit 065fc87306
14 files changed
+272 -33

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ What changed in each RustBox release, written for the people who use and run it.
## 0.2.28 ## 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. - 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. - 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 ## 0.2.27
+1 -1
View File
@@ -2,7 +2,7 @@ RustBox 0.2.28 release manifest
0.2.28 release changes: 0.2.28 release changes:
- replies to own sent messages target the other participant(s), with account aliases/identities treated as own addresses - 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 - 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: 0.2.27 release changes:
+2 -1
View File
@@ -1,7 +1,7 @@
RustBox 0.2.28 - final source validation RustBox 0.2.28 - final source validation
0.2.28 scope: 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: 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" - 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: Completed in this environment:
- offline release validator: scripts/validate-release-static.py (458/458 checks passed) - offline release validator: scripts/validate-release-static.py (458/458 checks passed)
- browser JavaScript assembled in manifest order passes `node --check` - 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 - 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 - 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 - all JSON files parse successfully
+1 -1
View File
@@ -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 ## 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. - 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. - 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 ## 0.2.27 mail-list and appearance polish
+1 -1
View File
@@ -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('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('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('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") 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") 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") 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")
+3
View File
@@ -404,6 +404,9 @@ pub async fn list_messages(
MessageSummary { MessageSummary {
uid: m.uid, uid: m.uid,
folder: m.folder, 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), subject: repair_common_mojibake(&m.subject),
from_address: m.from_address, from_address: m.from_address,
from_name: repair_common_mojibake(&m.from_name), from_name: repair_common_mojibake(&m.from_name),
+3
View File
@@ -68,6 +68,9 @@ pub(crate) struct ListMessagesResponse {
pub struct MessageSummary { pub struct MessageSummary {
pub uid: u32, pub uid: u32,
pub folder: String, pub folder: String,
pub message_id: Option<String>,
pub in_reply_to: Option<String>,
pub references: Option<String>,
pub subject: String, pub subject: String,
pub from_address: String, pub from_address: String,
pub from_name: String, pub from_name: String,
+9
View File
@@ -252,6 +252,9 @@ pub struct SearchResponse {
pub struct SearchResultItem { pub struct SearchResultItem {
pub uid: u32, pub uid: u32,
pub folder: String, pub folder: String,
pub message_id: Option<String>,
pub in_reply_to: Option<String>,
pub references: Option<String>,
pub score: f32, pub score: f32,
pub subject: String, pub subject: String,
pub from_address: String, pub from_address: String,
@@ -346,6 +349,9 @@ pub async fn search_messages(
SearchResultItem { SearchResultItem {
uid: msg.uid, uid: msg.uid,
folder: msg.folder, folder: msg.folder,
message_id: msg.message_id,
in_reply_to: msg.in_reply_to,
references: msg.references_header,
score: 1.0, score: 1.0,
subject: msg.subject, subject: msg.subject,
from_address: msg.from_address, from_address: msg.from_address,
@@ -414,6 +420,9 @@ pub async fn search_messages(
results.push(SearchResultItem { results.push(SearchResultItem {
uid: msg.uid, uid: msg.uid,
folder: msg.folder, folder: msg.folder,
message_id: msg.message_id,
in_reply_to: msg.in_reply_to,
references: msg.references_header,
score, score,
subject: msg.subject, subject: msg.subject,
from_address: msg.from_address, from_address: msg.from_address,
+3
View File
@@ -82,6 +82,9 @@ fn build_summary(m: db::messages::CachedMessage, tags: Vec<MessageTag>) -> Messa
MessageSummary { MessageSummary {
uid: m.uid, uid: m.uid,
folder: m.folder, folder: m.folder,
message_id: m.message_id,
in_reply_to: m.in_reply_to,
references: m.references_header,
subject: m.subject, subject: m.subject,
from_address: m.from_address, from_address: m.from_address,
from_name: m.from_name, from_name: m.from_name,
+52
View File
@@ -1543,3 +1543,55 @@ html[data-mail-embed="true"] #reader-fullscreen {
grid-column: 6; grid-column: 6;
} }
} }
/* 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;
}
+2
View File
@@ -849,6 +849,8 @@
"Single message": "Pojedyncza wiadomość", "Single message": "Pojedyncza wiadomość",
"Show single message": "Pokaż pojedynczą wiadomość", "Show single message": "Pokaż pojedynczą wiadomość",
"Show thread": "Pokaż wątek", "Show thread": "Pokaż wątek",
"Show messages": "Pokaż wiadomości osobno",
"Show threads": "Pokaż wątki",
"Remote images are blocked.": "Zdalne obrazy są zablokowane.", "Remote images are blocked.": "Zdalne obrazy są zablokowane.",
"Load images": "Wczytaj obrazy", "Load images": "Wczytaj obrazy",
"Trust sender": "Zaufaj nadawcy", "Trust sender": "Zaufaj nadawcy",
+4 -4
View File
@@ -152,6 +152,10 @@
<option value="size_asc">Smallest</option> <option value="size_asc">Smallest</option>
<option value="size_desc">Largest</option> <option value="size_desc">Largest</option>
</select></label> </select></label>
<button id="thread-mode-btn" class="icon-button" type="button" title="Show messages"
aria-label="Show messages" aria-pressed="true"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-list-ordered"></use>
</svg></button>
<button id="select-mode-btn" class="icon-button" type="button" title="Select messages" <button id="select-mode-btn" class="icon-button" type="button" title="Select messages"
aria-label="Select messages" aria-pressed="false"><svg> aria-label="Select messages" aria-pressed="false"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-check-circle"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-check-circle"></use>
@@ -233,10 +237,6 @@
aria-label="Open message full screen" aria-pressed="false"><svg> aria-label="Open message full screen" aria-pressed="false"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-external-link"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-external-link"></use>
</svg></button> </svg></button>
<button id="thread-mode-btn" class="button quiet hidden" type="button" title="Show single message"
aria-label="Show single message" aria-pressed="true"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-list"></use>
</svg><span id="thread-mode-label">Single message</span></button>
<button id="reply-btn" class="button quiet" type="button"><svg> <button id="reply-btn" class="button quiet" type="button"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply"></use>
</svg><span data-i18n="reply">Reply</span></button> </svg><span data-i18n="reply">Reply</span></button>
+1
View File
@@ -45,6 +45,7 @@
calendarCursor: new Date(), calendarCursor: new Date(),
calendarView: localStorage.getItem("rustbox.calendarView") || "month", calendarView: localStorage.getItem("rustbox.calendarView") || "month",
threadExpanded: localStorage.getItem("rustbox.threadExpanded") !== "false", threadExpanded: localStorage.getItem("rustbox.threadExpanded") !== "false",
threadListExpanded: new Set(),
calendarEvents: [], calendarEvents: [],
savedSearches: [], savedSearches: [],
responseTemplates: [], responseTemplates: [],
+189 -24
View File
@@ -207,7 +207,7 @@ function normalizedMessageFlags(value) {
} }
function findMessageRow(folder, uid) { 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) { function updateMessageRowState(folder, uid, flags) {
@@ -239,7 +239,11 @@ function applyLocalMessageFlags(folder, uid, flags) {
state.currentDetail.flags = [...normalized]; state.currentDetail.flags = [...normalized];
$("#flag-btn")?.classList.toggle("active", normalized.includes("\\Flagged")); $("#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) { function applyOptimisticMessageFlag(folder, uid, flag, add) {
@@ -281,7 +285,7 @@ function updateActiveMessageRow() {
for (const row of $$(".message-row")) { for (const row of $$(".message-row")) {
row.classList.toggle( row.classList.toggle(
"active", "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)); const sleeping = new Set((data.uids || []).map(String));
if (!sleeping.size || folder !== state.folder) return; if (!sleeping.size || folder !== state.folder) return;
for (const row of $$(".message-row", $("#message-list"))) { 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 */ } } 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() { function renderMessages() {
$("#folder-title").textContent = state.folder; $("#folder-title").textContent = state.folder;
const folderTotal = Number(state.folders.find((folder) => folder.name === state.folder)?.total_count); 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.classList.toggle("selection-mode", state.selectionMode);
list.replaceChildren(); 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>`; 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 folder = message.folder || state.folder;
const flags = Array.isArray(message.flags) ? message.flags.join(" ") : (message.flags || ""); const flags = Array.isArray(message.flags) ? message.flags.join(" ") : (message.flags || "");
const unread = !flags.includes("\\Seen"); const collapsedThreadHead = viewItem.threadHead && !viewItem.expanded && viewItem.members.length > 1;
const flagged = flags.includes("\\Flagged"); 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 checked = state.selectedMessages.has(messageKey(message));
const active = state.selected?.uid === message.uid && state.selected?.folder === folder; const active = collapsedThreadHead
const row = el("div", `message-row ${unread ? "unread" : ""} ${flagged ? "flagged" : ""} ${active ? "active" : ""} ${checked ? "selected" : ""}`); ? 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.folder = folder;
row.dataset.uid = String(message.uid); 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.tabIndex = 0;
row.setAttribute("role", "button"); 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"); const checkbox = document.createElement("input");
checkbox.type = "checkbox"; checkbox.type = "checkbox";
checkbox.className = "message-select"; checkbox.className = "message-select";
@@ -369,6 +504,23 @@ function renderMessages() {
const subjectNode = row.querySelector(".message-subject"); const subjectNode = row.querySelector(".message-subject");
renderHighlightedText(subjectNode, subjectText, highlightTerms); renderHighlightedText(subjectNode, subjectText, highlightTerms);
subjectNode.title = subjectText; 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); renderHighlightedText(row.querySelector(".message-snippet"), state.mailPrefs.show_snippets !== false ? (message.snippet || "") : "", highlightTerms);
const icons = row.querySelector(".message-icons"); const icons = row.querySelector(".message-icons");
if (message.has_attachments) { if (message.has_attachments) {
@@ -458,12 +610,17 @@ function renderMessages() {
async function navigateMessage(delta) { async function navigateMessage(delta) {
if (!state.selected) return; 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; 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 (next) return openMessage(next.folder || state.folder, next.uid);
if (!state.searchContext && mailboxLoadingMode() === "pagination" && await changeMailboxPage(delta > 0 ? 1 : -1)) { 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); if (boundary) return openMessage(boundary.folder || state.folder, boundary.uid);
} }
toast(delta > 0 ? "No next message" : "No previous message"); 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); if (delay === 0) run(); else state.markReadTimer = setTimeout(run, delay);
} }
function updateThreadModeButton(detail) { function updateThreadModeButton(_detail = null) {
const button = $("#thread-mode-btn"); const button = $("#thread-mode-btn");
const label = $("#thread-mode-label"); if (!button) return;
if (!button || !label) return;
const hasThread = (detail?.thread || []).length > 1;
const threaded = state.mailPrefs.thread_mode !== "flat"; const threaded = state.mailPrefs.thread_mode !== "flat";
button.classList.toggle("hidden", !hasThread);
button.setAttribute("aria-pressed", String(threaded)); 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.title = trText(action);
button.setAttribute("aria-label", 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() { async function toggleReaderThreadMode() {
if (!state.currentDetail || (state.currentDetail.thread || []).length <= 1) return;
const previous = state.mailPrefs.thread_mode || "threaded"; const previous = state.mailPrefs.thread_mode || "threaded";
const next = previous === "flat" ? "threaded" : "flat"; const next = previous === "flat" ? "threaded" : "flat";
state.mailPrefs.thread_mode = next; state.mailPrefs.thread_mode = next;
const select = $("#mail-pref-thread-mode"); const select = $("#mail-pref-thread-mode");
if (select) select.value = next; if (select) select.value = next;
renderThread(state.currentDetail); renderMessages();
if (state.currentDetail) renderThread(state.currentDetail);
void applySnoozeHiding();
try { try {
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify({ thread_mode: next }) }); state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify({ thread_mode: next }) });
if (select) select.value = state.mailPrefs.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) { } catch (error) {
state.mailPrefs.thread_mode = previous; state.mailPrefs.thread_mode = previous;
if (select) select.value = previous; if (select) select.value = previous;
renderThread(state.currentDetail); renderMessages();
if (state.currentDetail) renderThread(state.currentDetail);
void applySnoozeHiding();
toast(error.message, "error"); toast(error.message, "error");
} }
} }