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
+424 -33

No files matched your search

+5 -3
View File
@@ -944,10 +944,12 @@
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) {
.message-quick-actions {
opacity: 1;
pointer-events: auto;
.message-row .message-quick-actions {
display: none;
}
}
+171
View File
@@ -558,6 +558,177 @@
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 {
display: grid;
gap: 10px;
+3 -2
View File
@@ -1299,7 +1299,8 @@
"Attributes": "Atrybuty",
"Yes": "Tak",
"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ę.",
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
@@ -1330,4 +1331,4 @@
"account_color_note": "Pasek u góry aplikacji i kropka przy adresie pokazują, które konto jest aktywne.",
"contrast_warning": "Niski kontrast",
"edited_interface_style_note": "gotowy styl interfejsu z Twoim układem. Zmiana koloru tworzy własną kopię RustBox."
}
}
+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) {
const folder = message.folder || state.folder;
const rollback = applyOptimisticMessageFlag(folder, message.uid, flag, add);
@@ -571,7 +615,8 @@ function renderMessages() {
row.querySelector(".message-subject").after(wrap);
}
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) => {
event.dataTransfer.effectAllowed = "move";
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("click", (event) => {
if (row.dataset.longPressed === "1") {
row.dataset.longPressed = "";
event.preventDefault();
return;
}
if (state.selectionMode) {
if (event.shiftKey) {
event.preventDefault();
@@ -601,7 +651,16 @@ function renderMessages() {
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("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);
}
updateMessageLoadControls();
+140 -14
View File
@@ -377,8 +377,8 @@ async function deleteCurrent() {
} catch (error) { toast(error.message, "error"); }
}
async function performBulk(action, targetFolder = null) {
const items = selectedMessages();
async function performBulk(action, targetFolder = null, explicitItems = null) {
const items = explicitItems || selectedMessages();
if (!items.length) return;
const bulkJunkToInbox = action === "junk" && items.every((message) => isJunkFolder(message.folder || state.folder));
if (action === "archive" && !detectSpecialFolder("archive")) {
@@ -444,7 +444,7 @@ async function performBulk(action, targetFolder = null) {
completed += 1;
$("#bulk-count").textContent = `${completed}/${items.length}`;
}
state.selectedMessages.clear();
if (!explicitItems) state.selectedMessages.clear();
state.searchAllResults = null;
const flagsOnly = ["read", "unread", "flag", "unflag"].includes(action);
const orderingChanged = ["flag", "unflag"].includes(action) && state.currentFolderFlaggedOnTop && !state.searchContext;
@@ -674,11 +674,65 @@ async function deleteFolder() {
} catch (error) { toast(error.message, "error"); }
}
function hideContextMenu() {
function hideContextMenu({ fromHistory = false } = {}) {
closeContextSubmenu();
const menu = $("#context-menu");
const wasSheet = menu.classList.contains("context-sheet") && !menu.classList.contains("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();
$("#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 } = {}) {
@@ -692,8 +746,8 @@ function contextMenuButtons(menu, items, { submenuHost = null } = {}) {
button.setAttribute("aria-haspopup", "menu");
button.setAttribute("aria-expanded", "false");
button.append(el("span", "context-menu-arrow", "›"));
const open = () => openContextSubmenu(button, item.submenu);
button.addEventListener("mouseenter", open);
const open = () => (menu.closest(".context-sheet") ? openSheetSubmenu(menu, item) : openContextSubmenu(button, item.submenu));
if (!menu.closest(".context-sheet")) button.addEventListener("mouseenter", open);
button.addEventListener("click", (event) => { event.stopPropagation(); open(); });
button.addEventListener("keydown", (event) => {
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`;
}
// 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) {
const menu = $("#context-menu");
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") });
}
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) {
const flags = Array.isArray(message.flags) ? message.flags.join(" ") : String(message.flags || "");
const seen = flags.includes("\\Seen");
const flagged = flags.includes("\\Flagged");
const folder = message.folder || state.folder;
showContextMenu(x, y, messageContextItems(message));
}
// 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;
showContextMenu(x, y, [
return [
{ label: "Open", action: () => openMessage(folder, message.uid) },
{ label: "Open full screen", action: async () => { await ensureMessageOpen(message); enterReaderFullscreen(); } },
"separator",
@@ -783,8 +909,8 @@ function openMessageContextMenu(x, y, message) {
{ label: "Forward as attachment", action: async () => { await ensureMessageOpen(message); await forwardAsAttachment(); } },
] },
"separator",
{ label: seen ? "Mark unread" : "Mark read", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Seen", !seen); } },
{ label: flagged ? "Unflag" : "Flag", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Flagged", !flagged); } },
{ label: seen ? "Mark unread" : "Mark read", action: () => quickUpdateMessageFlag(message, "\\Seen", !seen, seen ? "Marked unread" : "Marked read") },
{ label: flagged ? "Unflag" : "Flag", action: () => quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged") },
{ label: "Labels", submenu: [
{ 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); } },
@@ -821,7 +947,7 @@ function openMessageContextMenu(x, y, message) {
] },
"separator",
{ label: "Delete", danger: true, action: async () => { await ensureMessageOpen(message); await deleteCurrent(); } },
]);
];
}
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; });
});
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) => {
const tag = document.activeElement?.tagName || "";
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 === "Delete") { event.preventDefault(); deleteCurrent(); }
}
if (event.key === "Escape" && contextSheetOpen()) {
event.preventDefault();
hideContextMenu();
return;
}
if (event.key === "Escape" && $("#reader")?.classList.contains("reader-fullscreen")) {
event.preventDefault();
exitReaderFullscreen();
@@ -733,6 +739,10 @@ function bindEvents() {
if (state.currentDetail && state.prefs.adaptiveEmailColors) renderMessageDetail(state.currentDetail);
}, 60000);
window.addEventListener("popstate", async (event) => {
if (contextSheetOpen() && !event.state?.rustboxActionSheet) {
hideContextMenu({ fromHistory: true });
return;
}
const full = event.state?.rustboxMailFullscreen === true;
if (!full) {
if ($("#reader")?.classList.contains("reader-fullscreen")) setReaderFullscreen(false, { pushHistory: false });