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

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-04 14:59:03 +01:00
1 parent 6e74b33f03
commit 3b9110c8c8
44 files changed
+3057 -128

No files matched your search

+38
View File
@@ -364,6 +364,44 @@
width: 100%;
}
.snoozed-message-list {
display: grid;
gap: 8px;
}
.snoozed-message-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border: 1px solid var(--border, #e2e2e2);
border-radius: 8px;
}
.snoozed-message-info {
display: grid;
gap: 2px;
min-width: 0;
}
.snoozed-message-info strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.snoozed-message-info span {
color: var(--muted);
font-size: 0.85em;
}
.snoozed-message-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.app-prompt-actions> :first-child {
margin-right: auto;
}
+61 -9
View File
@@ -98,6 +98,7 @@ async function loadMessages(reset = false, options = {}) {
}
$("#connection-label").textContent = "Connected";
renderMessages();
void applySnoozeHiding();
const desiredScroll = savedState?.scrollTop ?? previousScrollTop;
if (mode === "pagination") {
requestAnimationFrame(() => { list.scrollTop = 0; });
@@ -302,6 +303,24 @@ async function quickUpdateMessageFlag(message, flag, add, successMessage) {
}
}
// Hides rows for messages that are currently snoozed in this folder. This is
// a purely visual, best-effort filter on top of the already-rendered list:
// it never changes state.messages/state.total, so pagination/counts/caching
// above are completely unaffected. A message simply stops being hidden once
// its wake time passes and the folder is reloaded or reopened.
async function applySnoozeHiding() {
if (state.searchContext) return;
const folder = state.folder;
try {
const data = await request(`/folders/${encodeURIComponent(folder)}/snoozed-uids`, { method: "GET" });
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";
}
} catch (_) { /* best-effort visual filter only; never blocks the folder view */ }
}
function renderMessages() {
$("#folder-title").textContent = state.folder;
const folderTotal = Number(state.folders.find((folder) => folder.name === state.folder)?.total_count);
@@ -401,12 +420,24 @@ function renderMessages() {
});
row.addEventListener("dragend", () => row.classList.remove("dragging"));
row.addEventListener("click", (event) => {
if (event.shiftKey) {
if (state.selectionMode) {
if (event.shiftKey) {
event.preventDefault();
selectMessageRange(message);
return;
}
return setMessageSelected(message, !state.selectedMessages.has(messageKey(message)));
}
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
selectMessageRange(message);
openMessageInNewTab(folder, message.uid);
return;
}
if (event.shiftKey) {
event.preventDefault();
openMessageInPopup(folder, message.uid);
return;
}
if (state.selectionMode || event.ctrlKey || event.metaKey) return setMessageSelected(message, !state.selectedMessages.has(messageKey(message)));
setMessageSelectionAnchor(message);
openMessage(folder, message.uid);
});
@@ -455,16 +486,37 @@ async function senderIsSavedContact(email) {
}
function readerFullscreenUrl(detail = state.currentDetail) {
function messageDeepLinkUrl(folder, uid) {
const url = new URL(location.href);
if (detail?.folder && Number.isFinite(Number(detail.uid))) {
url.searchParams.set("mail_folder", detail.folder);
url.searchParams.set("mail_uid", String(detail.uid));
url.searchParams.set("mail_fullscreen", "1");
}
url.searchParams.set("mail_folder", folder);
url.searchParams.set("mail_uid", String(uid));
url.searchParams.set("mail_fullscreen", "1");
return url;
}
function readerFullscreenUrl(detail = state.currentDetail) {
if (detail?.folder && Number.isFinite(Number(detail.uid))) return messageDeepLinkUrl(detail.folder, detail.uid);
return new URL(location.href);
}
// Gmail-style modifier clicks on a message row, active only outside selection
// mode (inside selection mode the same modifiers keep doing range/toggle
// selection, see the row click handler below).
function openMessageInNewTab(folder, uid) {
window.open(messageDeepLinkUrl(folder, uid), "_blank", "noopener");
}
function openMessageInPopup(folder, uid) {
const width = Math.max(420, Math.min(900, Math.round(innerWidth * 0.8)));
const height = Math.max(360, Math.min(760, Math.round(innerHeight * 0.85)));
const left = Math.max(0, Math.round((innerWidth - width) / 2) + (window.screenX || 0));
const top = Math.max(0, Math.round((innerHeight - height) / 2) + (window.screenY || 0));
const features = `popup=1,noopener,width=${width},height=${height},left=${left},top=${top}`;
// A stable window name per message re-focuses the same popup on a second
// shift-click instead of spawning duplicates, matching normal browser behavior.
window.open(messageDeepLinkUrl(folder, uid), `rustbox-message-${folder}-${uid}`, features);
}
function restoreReaderScroll() {
const reader = $("#reader-content");
if (!reader) return;
+32 -2
View File
@@ -1788,6 +1788,32 @@ function buildReferences(detail) {
return values.length ? values.join(" ") : null;
}
// "Undo Send": the server held the just-sent message back for a few seconds
// instead of dispatching it immediately. Show a toast with an Undo action
// for the whole window, then poll once for the final result so a send that
// actually failed in the background is still reported.
function notifyUndoSendStaged(pendingId, windowSecs) {
const seconds = Math.max(0, windowSecs);
toast("Sending\u2026", "info", seconds * 1000 || 4000, {
label: "Undo",
onClick: async () => {
try {
await request(`/messages/send/${encodeURIComponent(pendingId)}`, { method: "DELETE" });
toast("Send cancelled", "success");
} catch (error) {
toast(error?.message || "Too late to cancel", "warning");
}
},
});
setTimeout(async () => {
try {
const final = await request(`/messages/send/${encodeURIComponent(pendingId)}`);
if (final?.status === "sent") toast("Message sent", "success");
else if (final?.status === "failed") toast(`Could not send: ${final.error || "unknown error"}`, "error");
} catch (_) { /* already swept or still dispatching; nothing useful to report */ }
}, seconds * 1000 + 1500);
}
async function sendMessage(event) {
event.preventDefault();
if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning");
@@ -1821,7 +1847,7 @@ async function sendMessage(event) {
setBusy(send, true);
const editor = $("#compose-editor");
try {
await request("/messages/send", {
const sendResult = await request("/messages/send", {
method: "POST",
body: JSON.stringify({
to: parseRecipients($("#compose-to").value),
@@ -1865,7 +1891,11 @@ async function sendMessage(event) {
state.pgpRecipientStatus = [];
$("#compose-pgp-passphrase").value = "";
updateComposeMessageSize();
toast("Message sent", "success");
if (sendResult?.status === "pending" && sendResult.pending_send_id) {
notifyUndoSendStaged(sendResult.pending_send_id, Number(sendResult.undo_window_secs) || 0);
} else {
toast("Message sent", "success");
}
await loadFolders();
} catch (error) {
state.composeDirty = true;
+127
View File
@@ -181,6 +181,131 @@ async function copyCurrentTo(targetFolder) {
} catch (error) { toast(error.message, "error"); }
}
// Snoozing hides a message from its folder listing until a chosen wake time.
// It is purely local client/server cache state: nothing moves on the real
// IMAP server, so a snooze never risks the actual mailbox.
function snoozePresetOptions() {
const now = new Date();
const laterToday = new Date(now.getTime() + 3 * 60 * 60 * 1000);
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
const nextWeek = new Date(now);
nextWeek.setDate(nextWeek.getDate() + (8 - nextWeek.getDay()) % 7 || 7);
nextWeek.setHours(9, 0, 0, 0);
const weekend = new Date(now);
const daysToSaturday = (6 - weekend.getDay() + 7) % 7 || 7;
weekend.setDate(weekend.getDate() + daysToSaturday);
weekend.setHours(9, 0, 0, 0);
return [
{ label: `Later today (${formatDate(laterToday.toISOString(), true)})`, value: String(Math.floor(laterToday.getTime() / 1000)) },
{ label: `Tomorrow (${formatDate(tomorrow.toISOString(), true)})`, value: String(Math.floor(tomorrow.getTime() / 1000)) },
{ label: `This weekend (${formatDate(weekend.toISOString(), true)})`, value: String(Math.floor(weekend.getTime() / 1000)) },
{ label: `Next week (${formatDate(nextWeek.toISOString(), true)})`, value: String(Math.floor(nextWeek.getTime() / 1000)) },
];
}
async function promptSnoozeCurrent() {
if (!state.currentDetail) return;
const wakeAt = await appDialog({
title: "Snooze until\u2026",
selectOptions: snoozePresetOptions(),
confirmLabel: "Snooze",
});
if (!wakeAt) return;
await snoozeCurrent(Number(wakeAt));
}
async function snoozeCurrent(wakeAtEpochSeconds) {
if (!state.currentDetail || !Number.isFinite(wakeAtEpochSeconds)) return;
const detail = state.currentDetail;
const nextTarget = nextMessageTargetAfterRemoval(detail.folder, detail.uid);
try {
await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/snooze`, {
method: "POST",
body: JSON.stringify({ wake_at: wakeAtEpochSeconds }),
});
await removeCurrentMessageFromView(detail.folder, detail.uid, nextTarget);
await refreshFolderListOnly();
toast(`Snoozed until ${formatDate(new Date(wakeAtEpochSeconds * 1000).toISOString(), true)}`, "success");
} catch (error) {
toast(error.message, "error");
}
}
async function showSnoozedMessages() {
let data;
try {
data = await request("/messages/snoozed", { method: "GET" });
} catch (error) {
toast(error.message, "error");
return;
}
const items = data.messages || [];
const dialog = el("dialog", "dialog app-prompt-dialog");
const form = el("div", "dialog-card app-prompt-card");
const header = el("header", "dialog-header");
header.append(el("strong", "", "Snoozed messages"));
const close = el("button", "icon-button");
close.type = "button";
close.setAttribute("aria-label", "Close");
close.innerHTML = svgIcon("x");
close.addEventListener("click", () => dialog.close());
header.append(close);
const body = el("div", "app-prompt-body");
if (!items.length) {
body.append(el("p", "app-prompt-message", "No snoozed messages."));
} else {
const list = el("div", "snoozed-message-list");
for (const item of items) {
const row = el("div", "snoozed-message-row");
const info = el("div", "snoozed-message-info");
info.append(
el("strong", "", item.subject || "(no subject)"),
el("span", "", item.from_name || item.from_address || ""),
el("span", "", `Wakes ${formatDate(new Date(item.wake_at * 1000).toISOString(), true)}`),
);
const actions = el("div", "snoozed-message-actions");
const openBtn = el("button", "button quiet", "Open");
openBtn.type = "button";
openBtn.addEventListener("click", async () => { dialog.close(); await openMessage(item.message_folder, item.message_uid); });
const wakeBtn = el("button", "button quiet", "Unsnooze now");
wakeBtn.type = "button";
wakeBtn.addEventListener("click", async () => {
if (await unsnoozeMessage(item.message_folder, item.message_uid)) {
row.remove();
if (state.folder === item.message_folder) await refreshFolderListOnly();
}
});
actions.append(openBtn, wakeBtn);
row.append(info, actions);
list.append(row);
}
body.append(list);
}
const actions = el("footer", "dialog-actions app-prompt-actions");
const okBtn = el("button", "button primary", "Close");
okBtn.type = "button";
okBtn.addEventListener("click", () => dialog.close());
actions.append(okBtn);
form.append(header, body, actions);
dialog.append(form);
dialog.addEventListener("close", () => dialog.remove());
document.body.append(dialog);
dialog.showModal();
}
async function unsnoozeMessage(folder, uid) {
try {
await request(`/messages/${encodeURIComponent(folder)}/${uid}/snooze`, { method: "DELETE" });
toast("Message unsnoozed", "success");
return true;
} catch (error) {
toast(error.message, "error");
return false;
}
}
const SPAM_DEFAULT_FOLDER_KEY = "rustbox.defaultSpamFolder";
function configuredBrowserSpamFolder() {
@@ -602,6 +727,8 @@ function openMessageContextMenu(x, y, message) {
{ label: flagged ? "Unflag" : "Flag", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Flagged", !flagged); } },
"separator",
{ label: "Archive", action: async () => { await ensureMessageOpen(message); await archiveCurrent(); } },
{ label: "Snooze\u2026", action: async () => { await ensureMessageOpen(message); await promptSnoozeCurrent(); } },
{ label: "View snoozed messages\u2026", action: async () => { await showSnoozedMessages(); } },
{ label: "Move to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Move to folder", message.folder || state.folder); if (target) await moveCurrentTo(target); } },
{ label: "Copy to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Copy to folder", message.folder || state.folder); if (target) await copyCurrentTo(target); } },
{ label: "Add label…", action: async () => { const tag = await promptTag("Add label"); if (tag) await addTagToMessage(message, tag); } },
+2
View File
@@ -678,6 +678,7 @@ function bindEvents() {
const globalKey = event.key.toLowerCase();
if (globalKey === "c") { event.preventDefault(); openCompose("new"); return; }
if (globalKey === "v") { event.preventDefault(); setSelectionMode(!state.selectionMode); return; }
if (globalKey === "b" && event.shiftKey) { event.preventDefault(); showSnoozedMessages(); return; }
}
if (appShortcut && state.currentDetail) {
const key = event.key.toLowerCase();
@@ -691,6 +692,7 @@ function bindEvents() {
else if (key === "a") { event.preventDefault(); openCompose("reply-all"); }
else if (key === "f") { event.preventDefault(); openCompose("forward"); }
else if (key === "e") { event.preventDefault(); archiveCurrent(); }
else if (key === "b") { event.preventDefault(); promptSnoozeCurrent(); }
else if (key === "m") { event.preventDefault(); toggleUnread(); }
else if (key === "s") { event.preventDefault(); toggleFlagged(); }
else if (event.key === "!") { event.preventDefault(); junkCurrent(); }