1 parent
c160f25e4c
commit
065fc87306
14 files changed
+272
-33
No files matched your search
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user