1 parent
6e74b33f03
commit
3b9110c8c8
44 files changed
+3057
-128
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); } },
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
Reference in new issue
Block a user