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
+273 -34

No files matched your search

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