v0.2.31
RustBox CI / rustbox (push) Waiting to run

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-08 16:42:41 +01:00
1 parent a3ac2a68fe
commit 2932578424
14 files changed
+423 -32

No files matched your search

+5
View File
@@ -2,6 +2,11 @@
What changed in each RustBox release, written for the people who use and run it. Technical details are in the [documentation](docs/). What changed in each RustBox release, written for the people who use and run it. Technical details are in the [documentation](docs/).
## 0.2.31
- Phones and tablets: press and hold a message in the list to open a bottom sheet with its actions (read, flag, archive, delete as large buttons, then reply, forward, labels, move, filters and the rest of the desktop menu). The hover buttons no longer cover the subject and date on touch screens.
- Mark read/unread and flag from the message context menu no longer open the message first.
## 0.2.30 ## 0.2.30
- Optional CalDAV and CardDAV server. With `[dav] enabled = true` every account gets a calendar and an address book at `/dav/` that phones, Thunderbird, DAVx⁵, Apple Calendar/Contacts or an ActiveSync gateway such as ActivePush can sync. It uses the same events and contacts as the webmail, so a change made in either place appears in the other. Sign in with your mail address and password. - Optional CalDAV and CardDAV server. With `[dav] enabled = true` every account gets a calendar and an address book at `/dav/` that phones, Thunderbird, DAVx⁵, Apple Calendar/Contacts or an ActiveSync gateway such as ActivePush can sync. It uses the same events and contacts as the webmail, so a change made in either place appears in the other. Sign in with your mail address and password.
Generated
+1 -1
View File
@@ -3534,7 +3534,7 @@ dependencies = [
[[package]] [[package]]
name = "rustbox" name = "rustbox"
version = "0.2.30" version = "0.2.31"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"async-imap", "async-imap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustbox" name = "rustbox"
version = "0.2.30" version = "0.2.31"
edition = "2024" edition = "2024"
rust-version = "1.99.1" rust-version = "1.99.1"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
+1 -1
View File
@@ -1,4 +1,4 @@
# RustBox 0.2.30 # RustBox 0.2.31
**Fast, self-hosted webmail written in Rust.** RustBox connects to any IMAP/SMTP server and ships as one executable with the whole interface built in, so there is no Node.js, PHP or separate frontend to deploy. **Fast, self-hosted webmail written in Rust.** RustBox connects to any IMAP/SMTP server and ships as one executable with the whole interface built in, so there is no Node.js, PHP or separate frontend to deploy.
+7 -3
View File
@@ -1,4 +1,8 @@
RustBox 0.2.30 release manifest RustBox 0.2.31 release manifest
0.2.31 release changes:
- touch screens: long press on a message row opens a bottom action sheet (read/unread, flag, archive, delete quick actions plus the full desktop context menu with in-place submenus); it follows the interface style colours, shape, scale and density, closes via backdrop, drag-down, Escape and Android back, and is centred on tablets; the hover quick-action overlay is hidden on touch screens instead of covering the subject and date
- message context menu: mark read/unread and flag no longer open the message first
0.2.30 release changes: 0.2.30 release changes:
- optional built-in CalDAV / CardDAV server (`[dav] enabled = true`): one calendar and one address book per account at `<base_path>/dav/`, backed by the webmail calendar and contacts tables; PROPFIND, REPORT (calendar-query, calendar-multiget, addressbook-query, addressbook-multiget), GET/HEAD, PUT, DELETE, ETag/If-Match/If-None-Match, CTag (`getctag`), RFC 6764 `/.well-known/caldav|carddav`; HTTP Basic auth against IMAP with a short-lived login cache and the webmail brute-force guard; uploaded iCalendar/vCard objects are stored verbatim and webmail edits are patched into them property by property (migration V055); see `docs/CALDAV_CARDDAV.md` - optional built-in CalDAV / CardDAV server (`[dav] enabled = true`): one calendar and one address book per account at `<base_path>/dav/`, backed by the webmail calendar and contacts tables; PROPFIND, REPORT (calendar-query, calendar-multiget, addressbook-query, addressbook-multiget), GET/HEAD, PUT, DELETE, ETag/If-Match/If-None-Match, CTag (`getctag`), RFC 6764 `/.well-known/caldav|carddav`; HTTP Basic auth against IMAP with a short-lived login cache and the webmail brute-force guard; uploaded iCalendar/vCard objects are stored verbatim and webmail edits are patched into them property by property (migration V055); see `docs/CALDAV_CARDDAV.md`
@@ -136,14 +140,14 @@ Docker examples:
Operations/docs: Operations/docs:
- standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets - standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets
- README.md documents 0.2.30 configuration - README.md documents 0.2.31 configuration
- docker/README.md documents Compose variants - docker/README.md documents Compose variants
- docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior - docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior
- TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending - TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending
Build/release metadata: Build/release metadata:
- `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts - `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts
- Cargo.toml version = 0.2.30 - Cargo.toml version = 0.2.31
- rust-toolchain.toml pins Rust 1.99.1 - rust-toolchain.toml pins Rust 1.99.1
- Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing - Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing
- CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build - CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build
+6 -3
View File
@@ -1,4 +1,7 @@
RustBox 0.2.30 - final source validation RustBox 0.2.31 - final source validation
0.2.31 scope:
- touch long-press action sheet on the message list, exercised in Playwright against the built server with a mocked API (iPhone 13, Pixel 7, iPad Pro 11 landscape; 8 interface styles at small/normal/large scale, compact/comfortable density, reduced motion; Android back, drag-down, Escape); desktop hover actions and right-click menu unchanged
0.2.30 scope: 0.2.30 scope:
- optional CalDAV/CardDAV server (`[dav]`, off by default) on the webmail calendar and contacts tables, migration V055; exercised end to end with the ActivePush client request set and the python-caldav client against a live server with a stub IMAP login - optional CalDAV/CardDAV server (`[dav]`, off by default) on the webmail calendar and contacts tables, migration V055; exercised end to end with the ActivePush client request set and the python-caldav client against a live server with a stub IMAP login
@@ -71,7 +74,7 @@ RustBox 0.2.30 - final source validation
- Composing shows only compact attachment/message size-limit values on one line - Composing shows only compact attachment/message size-limit values on one line
- 0.2.9 realtime notification gating, browser shortcut preservation and 300-message defaults remain intact - 0.2.9 realtime notification gating, browser shortcut preservation and 300-message defaults remain intact
- V047-V052 are consolidated into compatibility baseline V052; V053 adds `skin_depth` and `skin_gradients`; V054 adds the theme schedule and account colour; V055 adds the CalDAV/CardDAV columns (`dav_name`, `dav_data`) and a non-unique contacts e-mail index; V056 is next - V047-V052 are consolidated into compatibility baseline V052; V053 adds `skin_depth` and `skin_gradients`; V054 adds the theme schedule and account colour; V055 adds the CalDAV/CardDAV columns (`dav_name`, `dav_data`) and a non-unique contacts e-mail index; V056 is next
- version metadata identifies 0.2.30 - version metadata identifies 0.2.31
Completed in this environment: Completed in this environment:
- offline release validator: scripts/validate-release-static.py (467/467 checks passed) - offline release validator: scripts/validate-release-static.py (467/467 checks passed)
@@ -82,7 +85,7 @@ Completed in this environment:
- all JSON files parse successfully - all JSON files parse successfully
- Python helper scripts compile successfully - Python helper scripts compile successfully
- shell scripts pass `bash -n` - shell scripts pass `bash -n`
- release metadata consistently identifies 0.2.30 - release metadata consistently identifies 0.2.31
- V052 baseline consolidation plus the V053 skin-depth migration are validated offline - V052 baseline consolidation plus the V053 skin-depth migration are validated offline
- 0.2.25 filter UI (apply now, run now, always move from sender), context submenus and preset editing exercised in Playwright against a mocked API; the 0.2.24 styles rendered at standard and immersive depth with gradients on and off; earlier: all 37 interface styles rendered in light and dark at the immersive depth against a mocked API (Playwright), with no page errors - 0.2.25 filter UI (apply now, run now, always move from sender), context submenus and preset editing exercised in Playwright against a mocked API; the 0.2.24 styles rendered at standard and immersive depth with gradients on and off; earlier: all 37 interface styles rendered in light and dark at the immersive depth against a mocked API (Playwright), with no page errors
+4
View File
@@ -93,6 +93,10 @@ worker_max_attempts = 12
# new headers in other folders, and the automatic deep-index pass. Lower values make # new headers in other folders, and the automatic deep-index pass. Lower values make
# background state converge faster but increase IMAP traffic and server load. # background state converge faster but increase IMAP traffic and server load.
# Built-in CalDAV / CardDAV server (calendar + contacts for phones, ActivePush, DAVx5).
[dav]
enabled = true
# Administrator-only system diagnostics endpoint. Disabled by default. # Administrator-only system diagnostics endpoint. Disabled by default.
# When enabled it exposes a redacted runtime/configuration report plus live probes # When enabled it exposes a redacted runtime/configuration report plus live probes
# for SQL, session persistence (database/Redis), blob storage (filesystem/S3), # for SQL, session persistence (database/Redis), blob storage (filesystem/S3),
+8 -2
View File
@@ -1,7 +1,13 @@
# RustBox 0.2.30 - implementation status # RustBox 0.2.31 - implementation status
- Draft saves are realtime-silent: Drafts folder/list updates continue, but saving a draft does not surface as a new-mail notification. - Draft saves are realtime-silent: Drafts folder/list updates continue, but saving a draft does not surface as a new-mail notification.
This file tracks the `0.2.30` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. This file tracks the `0.2.31` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly.
## 0.2.31 touch action sheet
- On touch screens (`hover: none`) a long press (450 ms) on a message row opens `#context-menu` as a bottom sheet: sender/subject header, quick actions (read/unread and flag via the flags endpoint, archive/delete via `performBulk` on that message with undo), then the desktop context-menu items with drill-down submenus.
- The sheet uses the theme tokens (`--ui-size`, `--ui-scale-factor`, `--density-factor`, `--icon-size`, `--radius-*`), is capped at 560 px on tablets, owns one history entry (Android back closes it) and closes on backdrop tap, drag-down, Escape or any action. Rows are not draggable on touch screens; the hover quick-action overlay is hidden there.
- Context-menu "Mark read/unread" and "Flag/Unflag" update flags without opening the message.
## 0.2.30 CalDAV / CardDAV server ## 0.2.30 CalDAV / CardDAV server
+5 -3
View File
@@ -944,10 +944,12 @@
height: 12px; height: 12px;
} }
/* Touch screens have no hover: the overlay would permanently cover the subject
and date. Read/flag stay available in the reader toolbar and in selection
mode, and flagged rows keep their left stripe. */
@media (hover:none) { @media (hover:none) {
.message-quick-actions { .message-row .message-quick-actions {
opacity: 1; display: none;
pointer-events: auto;
} }
} }
+171
View File
@@ -558,6 +558,177 @@
background: var(--line); background: var(--line);
} }
/* Touch long-press: the message context menu as a bottom sheet. */
.context-sheet-backdrop {
position: fixed;
inset: 0;
z-index: 999;
background: rgb(0 0 0 / 45%);
animation: context-sheet-fade 160ms ease-out;
}
.context-menu.context-sheet {
top: auto;
right: 0;
bottom: 0;
left: 0;
width: 100%;
min-width: 0;
/* Tablets / landscape: a centred sheet instead of a full-width bar. */
max-width: min(100%, 560px);
margin-inline: auto;
transition: transform 160ms ease-out;
max-height: min(85vh, 85dvh);
display: flex;
flex-direction: column;
padding: 6px 10px calc(10px + env(safe-area-inset-bottom));
border-width: 1px 0 0;
/* Follows the style's shape: square styles (terminal, Windows) stay square. */
border-radius: min(calc(var(--radius-panel) * 3), 18px) min(calc(var(--radius-panel) * 3), 18px) 0 0;
font-size: var(--ui-size);
box-shadow: 0 -12px 32px var(--shadow);
overflow: hidden;
animation: context-sheet-up 200ms ease-out;
}
.context-menu.context-sheet:focus {
outline: none;
}
.context-sheet-handle,
.context-sheet-header {
touch-action: none;
}
.context-sheet-handle {
flex: none;
width: 38px;
height: 4px;
margin: 2px auto 8px;
border-radius: min(var(--radius-control), 2px);
background: var(--line-strong);
}
.context-sheet-header {
flex: none;
display: grid;
gap: 2px;
padding: 0 6px 10px;
min-width: 0;
}
.context-sheet-from,
.context-sheet-subject {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-sheet-from {
font-size: calc(var(--ui-size) * 1.15);
}
.context-sheet-subject {
color: var(--muted);
font-size: calc(var(--ui-size) * 1.05);
}
.context-sheet-quick {
flex: none;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.context-menu .context-sheet-quick-action {
min-height: calc(62px * var(--ui-scale-factor, 1));
flex-direction: column;
justify-content: center;
gap: 5px;
padding: 8px 2px;
border-radius: min(calc(var(--radius-control) * 3), 14px);
background: var(--hover);
color: var(--text);
font-size: calc(var(--ui-size) * 0.92);
text-align: center;
}
.context-sheet-quick-action span {
display: -webkit-box;
max-width: 100%;
overflow: hidden;
line-height: 1.2;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.context-sheet-quick-action svg {
width: calc(var(--icon-size, 18px) * 1.1);
height: calc(var(--icon-size, 18px) * 1.1);
}
.context-menu .context-sheet-quick-action.active {
color: var(--warning);
}
.context-menu .context-sheet-quick-action.danger {
color: var(--danger);
}
.context-sheet-list {
min-height: 0;
padding-top: 4px;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.context-sheet .context-sheet-list button {
min-height: max(44px, calc(42px * var(--ui-scale-factor, 1) * var(--density-factor, 1)));
padding: 10px 12px;
font-size: calc(var(--ui-size) * 1.2);
}
.context-sheet .context-sheet-list button.context-sheet-back {
color: var(--muted);
font-weight: 600;
}
@keyframes context-sheet-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes context-sheet-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.context-menu.context-sheet,
.context-sheet-backdrop {
animation: none;
}
}
@media (hover:none) {
/* Long press opens the action sheet instead of the iOS text callout. */
.message-row {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.message-row.long-pressing {
background: var(--hover);
transition: background 0.35s ease-in;
}
}
.identity-overview { .identity-overview {
display: grid; display: grid;
gap: 10px; gap: 10px;
+2 -1
View File
@@ -1299,7 +1299,8 @@
"Attributes": "Atrybuty", "Attributes": "Atrybuty",
"Yes": "Tak", "Yes": "Tak",
"No": "Nie", "No": "Nie",
"default": "domyślna" "default": "domyślna",
"Message actions": "Akcje wiadomości"
}, },
"fixed_interface_style_note": "gotowy styl interfejsu. Włącz edycję poniżej, aby dopasować jego układ i typografię.", "fixed_interface_style_note": "gotowy styl interfejsu. Włącz edycję poniżej, aby dopasować jego układ i typografię.",
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.", "custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
+61 -2
View File
@@ -290,6 +290,50 @@ function updateActiveMessageRow() {
} }
} }
// Touch screens (no hover) get a long-press action sheet instead of the hover
// buttons and the right-click menu.
const MESSAGE_LONG_PRESS_MS = 450;
function isTouchUi() {
return matchMedia("(hover: none)").matches;
}
function bindMessageLongPress(row, message) {
let timer = null;
let startX = 0;
let startY = 0;
const cancel = () => {
if (timer) clearTimeout(timer);
timer = null;
row.classList.remove("long-pressing");
};
row.addEventListener("touchstart", (event) => {
row.dataset.longPressed = "";
if (event.touches.length !== 1 || state.selectionMode) return cancel();
startX = event.touches[0].clientX;
startY = event.touches[0].clientY;
row.classList.add("long-pressing");
timer = setTimeout(() => {
timer = null;
row.classList.remove("long-pressing");
row.dataset.longPressed = "1";
navigator.vibrate?.(12);
openMessageActionSheet(message);
}, MESSAGE_LONG_PRESS_MS);
}, { passive: true });
row.addEventListener("touchmove", (event) => {
if (!timer) return;
const touch = event.touches[0];
if (Math.hypot(touch.clientX - startX, touch.clientY - startY) > 10) cancel();
}, { passive: true });
// After a long press the finger lift must not also open the message.
row.addEventListener("touchend", (event) => {
cancel();
if (row.dataset.longPressed === "1" && event.cancelable) event.preventDefault();
}, { passive: false });
row.addEventListener("touchcancel", cancel, { passive: true });
}
async function quickUpdateMessageFlag(message, flag, add, successMessage) { async function quickUpdateMessageFlag(message, flag, add, successMessage) {
const folder = message.folder || state.folder; const folder = message.folder || state.folder;
const rollback = applyOptimisticMessageFlag(folder, message.uid, flag, add); const rollback = applyOptimisticMessageFlag(folder, message.uid, flag, add);
@@ -571,7 +615,8 @@ function renderMessages() {
row.querySelector(".message-subject").after(wrap); row.querySelector(".message-subject").after(wrap);
} }
row.prepend(checkbox); row.prepend(checkbox);
row.draggable = true; // iOS/iPadOS start a drag on long press; that gesture opens the action sheet instead.
row.draggable = !isTouchUi();
row.addEventListener("dragstart", (event) => { row.addEventListener("dragstart", (event) => {
event.dataTransfer.effectAllowed = "move"; event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-rustbox-message", JSON.stringify({ folder, uid: message.uid })); event.dataTransfer.setData("application/x-rustbox-message", JSON.stringify({ folder, uid: message.uid }));
@@ -579,6 +624,11 @@ function renderMessages() {
}); });
row.addEventListener("dragend", () => row.classList.remove("dragging")); row.addEventListener("dragend", () => row.classList.remove("dragging"));
row.addEventListener("click", (event) => { row.addEventListener("click", (event) => {
if (row.dataset.longPressed === "1") {
row.dataset.longPressed = "";
event.preventDefault();
return;
}
if (state.selectionMode) { if (state.selectionMode) {
if (event.shiftKey) { if (event.shiftKey) {
event.preventDefault(); event.preventDefault();
@@ -601,7 +651,16 @@ function renderMessages() {
openMessage(folder, message.uid); openMessage(folder, message.uid);
}); });
row.addEventListener("keydown", (event) => { if (event.key === "Enter" && !state.selectionMode) openMessage(folder, message.uid); if (event.key === " ") { event.preventDefault(); setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); } }); row.addEventListener("keydown", (event) => { if (event.key === "Enter" && !state.selectionMode) openMessage(folder, message.uid); if (event.key === " ") { event.preventDefault(); setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); } });
row.addEventListener("contextmenu", (event) => { event.preventDefault(); openMessageContextMenu(event.clientX, event.clientY, message); }); row.addEventListener("contextmenu", (event) => {
event.preventDefault();
// Android fires contextmenu on long press as well; the sheet may already be open.
if (isTouchUi()) {
if (!contextSheetOpen()) openMessageActionSheet(message);
return;
}
openMessageContextMenu(event.clientX, event.clientY, message);
});
bindMessageLongPress(row, message);
list.append(row); list.append(row);
} }
updateMessageLoadControls(); updateMessageLoadControls();
+140 -14
View File
@@ -377,8 +377,8 @@ async function deleteCurrent() {
} catch (error) { toast(error.message, "error"); } } catch (error) { toast(error.message, "error"); }
} }
async function performBulk(action, targetFolder = null) { async function performBulk(action, targetFolder = null, explicitItems = null) {
const items = selectedMessages(); const items = explicitItems || selectedMessages();
if (!items.length) return; if (!items.length) return;
const bulkJunkToInbox = action === "junk" && items.every((message) => isJunkFolder(message.folder || state.folder)); const bulkJunkToInbox = action === "junk" && items.every((message) => isJunkFolder(message.folder || state.folder));
if (action === "archive" && !detectSpecialFolder("archive")) { if (action === "archive" && !detectSpecialFolder("archive")) {
@@ -444,7 +444,7 @@ async function performBulk(action, targetFolder = null) {
completed += 1; completed += 1;
$("#bulk-count").textContent = `${completed}/${items.length}`; $("#bulk-count").textContent = `${completed}/${items.length}`;
} }
state.selectedMessages.clear(); if (!explicitItems) state.selectedMessages.clear();
state.searchAllResults = null; state.searchAllResults = null;
const flagsOnly = ["read", "unread", "flag", "unflag"].includes(action); const flagsOnly = ["read", "unread", "flag", "unflag"].includes(action);
const orderingChanged = ["flag", "unflag"].includes(action) && state.currentFolderFlaggedOnTop && !state.searchContext; const orderingChanged = ["flag", "unflag"].includes(action) && state.currentFolderFlaggedOnTop && !state.searchContext;
@@ -674,11 +674,65 @@ async function deleteFolder() {
} catch (error) { toast(error.message, "error"); } } catch (error) { toast(error.message, "error"); }
} }
function hideContextMenu() { function hideContextMenu({ fromHistory = false } = {}) {
closeContextSubmenu(); closeContextSubmenu();
const menu = $("#context-menu"); const menu = $("#context-menu");
const wasSheet = menu.classList.contains("context-sheet") && !menu.classList.contains("hidden");
menu.classList.add("hidden"); menu.classList.add("hidden");
menu.classList.remove("context-sheet");
menu.setAttribute("role", "menu");
menu.removeAttribute("aria-label");
menu.removeAttribute("aria-modal");
menu.removeAttribute("tabindex");
menu.style.removeProperty("transform");
menu.style.removeProperty("left");
menu.style.removeProperty("top");
menu.style.removeProperty("max-height");
menu.replaceChildren(); menu.replaceChildren();
$("#context-sheet-backdrop")?.remove();
if (wasSheet) {
// The sheet owns one history entry so Android "back" closes it.
if (!fromHistory && history.state?.rustboxActionSheet) history.back();
const returnTo = contextSheetReturnFocus;
contextSheetReturnFocus = null;
if (returnTo?.isConnected) returnTo.focus({ preventScroll: true });
}
}
let contextSheetReturnFocus = null;
// Drag the handle/header down to dismiss the sheet.
function bindContextSheetDrag(menu, zones) {
let startY = null;
let delta = 0;
const move = (event) => {
if (startY === null) return;
delta = Math.max(0, event.touches[0].clientY - startY);
menu.style.transform = `translateY(${delta}px)`;
};
const end = () => {
if (startY === null) return;
startY = null;
menu.style.removeProperty("transition");
if (delta > Math.min(120, menu.offsetHeight / 4)) hideContextMenu();
else menu.style.removeProperty("transform");
delta = 0;
};
for (const zone of zones) {
zone.addEventListener("touchstart", (event) => {
if (event.touches.length !== 1) return;
startY = event.touches[0].clientY;
delta = 0;
menu.style.transition = "none";
}, { passive: true });
zone.addEventListener("touchmove", move, { passive: true });
zone.addEventListener("touchend", end, { passive: true });
zone.addEventListener("touchcancel", end, { passive: true });
}
}
function contextSheetOpen() {
return $("#context-menu")?.classList.contains("context-sheet") && !$("#context-menu").classList.contains("hidden");
} }
function contextMenuButtons(menu, items, { submenuHost = null } = {}) { function contextMenuButtons(menu, items, { submenuHost = null } = {}) {
@@ -692,8 +746,8 @@ function contextMenuButtons(menu, items, { submenuHost = null } = {}) {
button.setAttribute("aria-haspopup", "menu"); button.setAttribute("aria-haspopup", "menu");
button.setAttribute("aria-expanded", "false"); button.setAttribute("aria-expanded", "false");
button.append(el("span", "context-menu-arrow", "›")); button.append(el("span", "context-menu-arrow", "›"));
const open = () => openContextSubmenu(button, item.submenu); const open = () => (menu.closest(".context-sheet") ? openSheetSubmenu(menu, item) : openContextSubmenu(button, item.submenu));
button.addEventListener("mouseenter", open); if (!menu.closest(".context-sheet")) button.addEventListener("mouseenter", open);
button.addEventListener("click", (event) => { event.stopPropagation(); open(); }); button.addEventListener("click", (event) => { event.stopPropagation(); open(); });
button.addEventListener("keydown", (event) => { button.addEventListener("keydown", (event) => {
if (event.key === "ArrowRight" || event.key === "Enter" || event.key === " ") { if (event.key === "ArrowRight" || event.key === "Enter" || event.key === " ") {
@@ -744,6 +798,19 @@ function openContextSubmenu(anchor, items) {
submenu.style.top = `${Math.max(6, Math.min(anchorRect.top - 4, innerHeight - rect.height - 6))}px`; submenu.style.top = `${Math.max(6, Math.min(anchorRect.top - 4, innerHeight - rect.height - 6))}px`;
} }
// Touch action sheet: submenus replace the list in place, with a way back.
function openSheetSubmenu(list, item) {
const previous = [...list.childNodes];
list.replaceChildren();
const back = el("button", "context-sheet-back", `‹ ${trText(item.label)}`);
back.type = "button";
back.setAttribute("aria-label", trText("Back"));
back.addEventListener("click", (event) => { event.stopPropagation(); list.replaceChildren(...previous); });
list.append(back, el("div", "separator"));
contextMenuButtons(list, item.submenu);
list.scrollTop = 0;
}
function showContextMenu(x, y, items) { function showContextMenu(x, y, items) {
const menu = $("#context-menu"); const menu = $("#context-menu");
closeContextSubmenu(); closeContextSubmenu();
@@ -766,13 +833,72 @@ async function promptFolder(label, exclude = null) {
return appSelect(trText(label), options.map((name) => ({ value: name, label: name })), { value: options[0], confirmLabel: trText("Choose folder") }); return appSelect(trText(label), options.map((name) => ({ value: name, label: name })), { value: options[0], confirmLabel: trText("Choose folder") });
} }
function messageMenuState(message) {
const flags = normalizedMessageFlags(message.flags);
return { seen: flags.includes("\\Seen"), flagged: flags.includes("\\Flagged"), folder: message.folder || state.folder };
}
function openMessageContextMenu(x, y, message) { function openMessageContextMenu(x, y, message) {
const flags = Array.isArray(message.flags) ? message.flags.join(" ") : String(message.flags || ""); showContextMenu(x, y, messageContextItems(message));
const seen = flags.includes("\\Seen"); }
const flagged = flags.includes("\\Flagged");
const folder = message.folder || state.folder; // Long-press menu for touch screens: the desktop context menu as a bottom sheet,
// headed by the row's hover actions (read, flag, archive, delete).
function openMessageActionSheet(message) {
const { seen, flagged } = messageMenuState(message);
const menu = $("#context-menu");
hideContextMenu();
const backdrop = el("div", "context-sheet-backdrop");
backdrop.id = "context-sheet-backdrop";
document.body.append(backdrop);
menu.classList.add("context-sheet");
menu.setAttribute("role", "dialog");
menu.setAttribute("aria-modal", "true");
menu.setAttribute("aria-label", trText("Message actions"));
menu.tabIndex = -1;
contextSheetReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const header = el("div", "context-sheet-header");
header.append(
el("strong", "context-sheet-from", message.from_name || message.from_address || trText("Unknown")),
el("span", "context-sheet-subject", message.subject || trText("(no subject)")),
);
const quick = el("div", "context-sheet-quick");
const quickButton = (icon, label, action, extra = "") => {
const button = el("button", `context-sheet-quick-action ${extra}`);
button.type = "button";
button.innerHTML = svgIcon(icon);
button.append(el("span", "", trText(label)));
button.addEventListener("click", async (event) => { event.stopPropagation(); hideContextMenu(); await action(); });
quick.append(button);
};
quickButton(seen ? "mail" : "mail-open", seen ? "Mark unread" : "Mark read",
() => quickUpdateMessageFlag(message, "\\Seen", !seen, seen ? "Marked unread" : "Marked read"));
quickButton("star", flagged ? "Unflag" : "Flag",
() => quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged"), flagged ? "active" : "");
quickButton("archive", "Archive", () => performBulk("archive", null, [message]));
quickButton("trash", "Delete", () => performBulk("delete", null, [message]), "danger");
const list = el("div", "context-sheet-list");
list.setAttribute("role", "menu");
const handle = el("div", "context-sheet-handle");
menu.replaceChildren(handle, header, quick, list);
bindContextSheetDrag(menu, [handle, header]);
// Read/flag/archive/delete are in the quick row above; keep the rest.
const skip = new Set(["Mark read", "Mark unread", "Flag", "Unflag", "Archive", "Delete", "Open full screen"]);
const items = messageContextItems(message).filter((item) => item === "separator" || !skip.has(item.label));
while (items[0] === "separator") items.shift();
while (items.at(-1) === "separator") items.pop();
contextMenuButtons(list, items.filter((item, i) => !(item === "separator" && items[i - 1] === "separator")));
menu.classList.remove("hidden");
if (!history.state?.rustboxActionSheet) history.pushState({ ...(history.state || {}), rustboxActionSheet: true }, "");
menu.focus({ preventScroll: true });
}
function messageContextItems(message) {
const { seen, flagged, folder } = messageMenuState(message);
const sieveEnabled = state.authPolicy.sieveEnabled !== false; const sieveEnabled = state.authPolicy.sieveEnabled !== false;
showContextMenu(x, y, [ return [
{ label: "Open", action: () => openMessage(folder, message.uid) }, { label: "Open", action: () => openMessage(folder, message.uid) },
{ label: "Open full screen", action: async () => { await ensureMessageOpen(message); enterReaderFullscreen(); } }, { label: "Open full screen", action: async () => { await ensureMessageOpen(message); enterReaderFullscreen(); } },
"separator", "separator",
@@ -783,8 +909,8 @@ function openMessageContextMenu(x, y, message) {
{ label: "Forward as attachment", action: async () => { await ensureMessageOpen(message); await forwardAsAttachment(); } }, { label: "Forward as attachment", action: async () => { await ensureMessageOpen(message); await forwardAsAttachment(); } },
] }, ] },
"separator", "separator",
{ label: seen ? "Mark unread" : "Mark read", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Seen", !seen); } }, { label: seen ? "Mark unread" : "Mark read", action: () => quickUpdateMessageFlag(message, "\\Seen", !seen, seen ? "Marked unread" : "Marked read") },
{ label: flagged ? "Unflag" : "Flag", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Flagged", !flagged); } }, { label: flagged ? "Unflag" : "Flag", action: () => quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged") },
{ label: "Labels", submenu: [ { label: "Labels", submenu: [
{ label: "Add label…", action: async () => { const tag = await promptTag("Add label"); if (tag) await addTagToMessage(message, tag); } }, { label: "Add label…", action: async () => { const tag = await promptTag("Add label"); if (tag) await addTagToMessage(message, tag); } },
{ label: "Remove label…", action: async () => { const attached = state.tags.filter((tag) => (message.tags || []).some((item) => item.id === tag.id)); const tag = await promptTag("Remove label", attached); if (tag) await removeTagFromMessage(message, tag); } }, { label: "Remove label…", action: async () => { const attached = state.tags.filter((tag) => (message.tags || []).some((item) => item.id === tag.id)); const tag = await promptTag("Remove label", attached); if (tag) await removeTagFromMessage(message, tag); } },
@@ -821,7 +947,7 @@ function openMessageContextMenu(x, y, message) {
] }, ] },
"separator", "separator",
{ label: "Delete", danger: true, action: async () => { await ensureMessageOpen(message); await deleteCurrent(); } }, { label: "Delete", danger: true, action: async () => { await ensureMessageOpen(message); await deleteCurrent(); } },
]); ];
} }
async function configureFolderFlaggedOnTop(folder) { async function configureFolderFlaggedOnTop(folder) {
+11 -1
View File
@@ -675,7 +675,8 @@ function bindEvents() {
if (!event.target.closest(".toolbar-menu, .toolbar-menu-popover[data-toolbar-menu-owner]")) $$(".toolbar-menu[open]").forEach((menu) => { menu.open = false; }); if (!event.target.closest(".toolbar-menu, .toolbar-menu-popover[data-toolbar-menu-owner]")) $$(".toolbar-menu[open]").forEach((menu) => { menu.open = false; });
}); });
window.addEventListener("blur", hideContextMenu); window.addEventListener("blur", hideContextMenu);
document.addEventListener("scroll", (event) => { if (!event.target?.closest?.(".context-menu")) hideContextMenu(); }, true); // The touch action sheet stays open while the list behind it moves; it closes via the backdrop.
document.addEventListener("scroll", (event) => { if (!event.target?.closest?.(".context-menu") && !contextSheetOpen()) hideContextMenu(); }, true);
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
const tag = document.activeElement?.tagName || ""; const tag = document.activeElement?.tagName || "";
if (event.key === "/" && !["INPUT", "TEXTAREA"].includes(tag) && !document.activeElement?.isContentEditable) { if (event.key === "/" && !["INPUT", "TEXTAREA"].includes(tag) && !document.activeElement?.isContentEditable) {
@@ -712,6 +713,11 @@ function bindEvents() {
else if (event.key === "!") { event.preventDefault(); junkCurrent(); } else if (event.key === "!") { event.preventDefault(); junkCurrent(); }
else if (event.key === "Delete") { event.preventDefault(); deleteCurrent(); } else if (event.key === "Delete") { event.preventDefault(); deleteCurrent(); }
} }
if (event.key === "Escape" && contextSheetOpen()) {
event.preventDefault();
hideContextMenu();
return;
}
if (event.key === "Escape" && $("#reader")?.classList.contains("reader-fullscreen")) { if (event.key === "Escape" && $("#reader")?.classList.contains("reader-fullscreen")) {
event.preventDefault(); event.preventDefault();
exitReaderFullscreen(); exitReaderFullscreen();
@@ -733,6 +739,10 @@ function bindEvents() {
if (state.currentDetail && state.prefs.adaptiveEmailColors) renderMessageDetail(state.currentDetail); if (state.currentDetail && state.prefs.adaptiveEmailColors) renderMessageDetail(state.currentDetail);
}, 60000); }, 60000);
window.addEventListener("popstate", async (event) => { window.addEventListener("popstate", async (event) => {
if (contextSheetOpen() && !event.state?.rustboxActionSheet) {
hideContextMenu({ fromHistory: true });
return;
}
const full = event.state?.rustboxMailFullscreen === true; const full = event.state?.rustboxMailFullscreen === true;
if (!full) { if (!full) {
if ($("#reader")?.classList.contains("reader-fullscreen")) setReaderFullscreen(false, { pushHistory: false }); if ($("#reader")?.classList.contains("reader-fullscreen")) setReaderFullscreen(false, { pushHistory: false });