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

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 00:12:09 +01:00
1 parent a238ff5e23
commit 93762fe9ef
65 files changed
+5420 -2792

No files matched your search

+1 -1
View File
@@ -68,7 +68,7 @@
imageEditorBlob: null,
draftSaveTimer: null,
prefs: {
theme: "system", interfaceSkin: "rustbox", density: "compact", preview: "right", scale: "normal", language: "en", composeFormat: "html",
theme: root.dataset.defaultTheme || "system", interfaceSkin: "default", presetScaleUnlocked: false, density: "compact", preview: "right", scale: "normal", language: "en", composeFormat: "html",
composeAutosaveMs: 3000, defaultPriority: "normal", requestMdnDefault: false, attachmentReminder: true, composeFontFamily: "system", replyPosition: "above", quoteStyle: "standard", forwardMode: "inline", spellcheckEnabled: true,
colorPreset: "sage", accentColor: "#6f9c86", backgroundColor: "#121513", panelColor: "#181c19", textColor: "#edf0ec", fontFamily: "system", fontSize: 13,
rowHeight: 58, folderPaneWidth: 228, messagePaneWidth: 420, borderRadius: 4,
+371 -304
View File
@@ -1,333 +1,400 @@
/* RustBox UI module: 10-core.js */
function setConnectivityState(online, detail = "") {
state.networkOnline = Boolean(online);
document.body.classList.toggle("network-offline", !state.networkOnline);
const banner = $("#offline-banner");
if (banner) {
banner.classList.toggle("hidden", state.networkOnline);
banner.textContent = detail || (navigator.onLine ? "RustBox cannot reach the server. Read-only cached content may still be visible." : "You are offline. RustBox will reconnect when the network returns.");
}
const label = $("#connection-label");
if (!label) return;
if (!state.networkOnline) label.textContent = navigator.onLine ? "Reconnecting" : "Offline";
else if (["Offline", "Reconnecting", "Connection error"].includes(label.textContent)) label.textContent = "Connected";
function setConnectivityState(online, detail = "") {
state.networkOnline = Boolean(online);
document.body.classList.toggle("network-offline", !state.networkOnline);
const banner = $("#offline-banner");
if (banner) {
banner.classList.toggle("hidden", state.networkOnline);
banner.textContent = detail || (navigator.onLine ? "RustBox cannot reach the server. Read-only cached content may still be visible." : "You are offline. RustBox will reconnect when the network returns.");
}
const label = $("#connection-label");
if (!label) return;
if (!state.networkOnline) label.textContent = navigator.onLine ? "Reconnecting" : "Offline";
else if (["Offline", "Reconnecting", "Connection error"].includes(label.textContent)) label.textContent = "Connected";
}
function listStateStorageKey(folder = state.folder) {
return `rustbox.listState.${state.accountId || "anonymous"}.${encodeURIComponent(folder || "INBOX")}`;
function listStateStorageKey(folder = state.folder) {
return `rustbox.listState.${state.accountId || "anonymous"}.${encodeURIComponent(folder || "INBOX")}`;
}
function lastFolderStorageKey() {
return `rustbox.lastFolder.${state.accountId || "anonymous"}`;
}
function activeSearchStorageKey() {
return `rustbox.activeSearch.${state.accountId || "anonymous"}`;
}
function saveActiveSearchContext() {
if (!state.searchContext) return sessionStorage.removeItem(activeSearchStorageKey());
sessionStorage.setItem(activeSearchStorageKey(), JSON.stringify(state.searchContext));
}
function readActiveSearchContext() {
try {
const parsed = JSON.parse(sessionStorage.getItem(activeSearchStorageKey()) || "null");
return parsed && typeof parsed.params === "string" ? parsed : null;
} catch (_) {
return null;
}
}
function lastFolderStorageKey() {
return `rustbox.lastFolder.${state.accountId || "anonymous"}`;
function clearActiveSearchContext() {
state.searchContext = null;
state.searchAllResults = null;
sessionStorage.removeItem(activeSearchStorageKey());
}
function readMessageListState(folder = state.folder) {
try {
const parsed = JSON.parse(localStorage.getItem(listStateStorageKey(folder)) || "null");
if (!parsed || typeof parsed !== "object") return null;
return {
scrollTop: Math.max(0, Number(parsed.scrollTop) || 0),
page: Math.max(0, Number(parsed.page) || 0),
};
} catch (_) {
return null;
}
}
function activeSearchStorageKey() {
return `rustbox.activeSearch.${state.accountId || "anonymous"}`;
function saveMessageListState() {
if (!state.accountId) return;
const list = $("#message-list");
if (!list) return;
if (state.searchContext) {
state.searchContext.scrollTop = list.scrollTop;
saveActiveSearchContext();
return;
}
localStorage.setItem(listStateStorageKey(), JSON.stringify({ scrollTop: list.scrollTop, page: state.page }));
localStorage.setItem(lastFolderStorageKey(), state.folder);
}
function saveActiveSearchContext() {
if (!state.searchContext) return sessionStorage.removeItem(activeSearchStorageKey());
sessionStorage.setItem(activeSearchStorageKey(), JSON.stringify(state.searchContext));
}
function scheduleMessageListStateSave() {
clearTimeout(state.listStateTimer);
state.listStateTimer = setTimeout(saveMessageListState, 120);
}
function readActiveSearchContext() {
async function request(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.accountId) headers.set("X-Active-Account", state.accountId);
if (options.body && !(options.body instanceof FormData)) headers.set("Content-Type", "application/json");
if (options.method && options.method !== "GET") headers.set("X-Requested-With", "XMLHttpRequest");
const method = String(options.method || "GET").toUpperCase();
const maxAttempts = method === "GET" || method === "HEAD" ? 3 : 1;
let lastNetworkError = null;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const parsed = JSON.parse(sessionStorage.getItem(activeSearchStorageKey()) || "null");
return parsed && typeof parsed.params === "string" ? parsed : null;
} catch (_) {
return null;
}
}
function clearActiveSearchContext() {
state.searchContext = null;
state.searchAllResults = null;
sessionStorage.removeItem(activeSearchStorageKey());
}
function readMessageListState(folder = state.folder) {
try {
const parsed = JSON.parse(localStorage.getItem(listStateStorageKey(folder)) || "null");
if (!parsed || typeof parsed !== "object") return null;
return {
scrollTop: Math.max(0, Number(parsed.scrollTop) || 0),
page: Math.max(0, Number(parsed.page) || 0),
};
} catch (_) {
return null;
}
}
function saveMessageListState() {
if (!state.accountId) return;
const list = $("#message-list");
if (!list) return;
if (state.searchContext) {
state.searchContext.scrollTop = list.scrollTop;
saveActiveSearchContext();
return;
}
localStorage.setItem(listStateStorageKey(), JSON.stringify({ scrollTop: list.scrollTop, page: state.page }));
localStorage.setItem(lastFolderStorageKey(), state.folder);
}
function scheduleMessageListStateSave() {
clearTimeout(state.listStateTimer);
state.listStateTimer = setTimeout(saveMessageListState, 120);
}
async function request(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.accountId) headers.set("X-Active-Account", state.accountId);
if (options.body && !(options.body instanceof FormData)) headers.set("Content-Type", "application/json");
if (options.method && options.method !== "GET") headers.set("X-Requested-With", "XMLHttpRequest");
const method = String(options.method || "GET").toUpperCase();
const maxAttempts = method === "GET" || method === "HEAD" ? 3 : 1;
let lastNetworkError = null;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const response = await fetch(apiPath(path), {
credentials: "include",
...options,
headers,
});
if ([502, 503, 504].includes(response.status) && attempt + 1 < maxAttempts) {
await sleep(250 * (attempt + 1));
continue;
}
const contentType = response.headers.get("content-type") || "";
const payload = contentType.includes("application/json")
? await response.json().catch(() => ({}))
: await response.text().catch(() => "");
if (!response.ok) {
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Request failed (${response.status})`;
const error = new Error(message);
error.status = response.status;
throw error;
}
setConnectivityState(true);
return payload;
} catch (error) {
if (error?.status) throw error;
lastNetworkError = error;
setConnectivityState(false);
if (attempt + 1 < maxAttempts) {
await sleep(350 * (attempt + 1));
continue;
}
}
}
const error = new Error(navigator.onLine ? "RustBox server is temporarily unreachable" : "You are offline");
error.cause = lastNetworkError;
error.network = true;
throw error;
}
function uploadRequest(path, formData, onProgress = null) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", apiPath(path), true);
xhr.withCredentials = true;
if (state.accountId) xhr.setRequestHeader("X-Active-Account", state.accountId);
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
if (typeof onProgress === "function") {
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && event.total > 0) onProgress(Math.round((event.loaded / event.total) * 100));
});
}
xhr.addEventListener("error", () => reject(new Error("Upload failed: network error")));
xhr.addEventListener("abort", () => reject(new Error("Upload cancelled")));
xhr.addEventListener("load", () => {
const contentType = xhr.getResponseHeader("content-type") || "";
let payload = xhr.responseText || "";
if (contentType.includes("application/json")) {
try { payload = payload ? JSON.parse(payload) : {}; } catch (_) { payload = {}; }
}
if (xhr.status < 200 || xhr.status >= 300) {
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Upload failed (${xhr.status})`;
const error = new Error(message);
error.status = xhr.status;
reject(error);
return;
}
resolve(payload);
const response = await fetch(apiPath(path), {
credentials: "include",
...options,
headers,
});
xhr.send(formData);
});
}
function toast(message, kind = "info", duration = null, action = null) {
const normalized = ["success", "error", "warning", "info"].includes(kind) ? kind : "info";
const lifetime = Number(duration) > 0 ? Number(duration) : normalized === "error" ? 6500 : normalized === "warning" ? 5200 : 3800;
const node = el("div", `toast toast-${normalized}`);
node.setAttribute("role", normalized === "error" ? "alert" : "status");
const iconName = normalized === "success" ? "check-circle" : normalized === "error" || normalized === "warning" ? "circle-alert" : "info";
const icon = el("span", "toast-icon");
icon.innerHTML = svgIcon(iconName);
const text = el("span", "toast-message", message);
let actionButton = null;
if (action?.label && typeof action.onClick === "function") {
actionButton = el("button", "toast-action", action.label);
actionButton.type = "button";
}
const countdown = el("span", "toast-countdown");
countdown.setAttribute("aria-hidden", "true");
const close = el("button", "toast-close");
close.type = "button";
close.setAttribute("aria-label", "Dismiss notification");
close.innerHTML = svgIcon("x");
const controls = el("span", "toast-controls");
if (actionButton) controls.append(actionButton);
controls.append(close);
node.append(icon, text, countdown, controls);
const openDialogs = $$("dialog[open]");
let stack = $("#toast-stack");
if (openDialogs.length) {
const dialog = openDialogs[openDialogs.length - 1];
stack = dialog.querySelector(":scope > .modal-toast-stack");
if (!stack) {
stack = el("div", "toast-stack modal-toast-stack");
stack.setAttribute("aria-live", "polite");
dialog.append(stack);
if ([502, 503, 504].includes(response.status) && attempt + 1 < maxAttempts) {
await sleep(250 * (attempt + 1));
continue;
}
const contentType = response.headers.get("content-type") || "";
const payload = contentType.includes("application/json")
? await response.json().catch(() => ({}))
: await response.text().catch(() => "");
if (!response.ok) {
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Request failed (${response.status})`;
const error = new Error(message);
error.status = response.status;
throw error;
}
setConnectivityState(true);
return payload;
} catch (error) {
if (error?.status) throw error;
lastNetworkError = error;
setConnectivityState(false);
if (attempt + 1 < maxAttempts) {
await sleep(350 * (attempt + 1));
continue;
}
}
stack.append(node);
requestAnimationFrame(() => node.classList.add("visible"));
}
const error = new Error(navigator.onLine ? "RustBox server is temporarily unreachable" : "You are offline");
error.cause = lastNetworkError;
error.network = true;
throw error;
}
let remaining = lifetime;
let startedAt = 0;
let timeout = null;
let ticker = null;
let dismissed = false;
const renderCountdown = () => { countdown.textContent = `${Math.max(1, Math.ceil(remaining / 1000))}s`; };
const stopTimers = () => { clearTimeout(timeout); clearInterval(ticker); timeout = null; ticker = null; };
const dismiss = (immediate = false) => {
if (dismissed) return;
dismissed = true;
stopTimers();
if (immediate) return node.remove();
node.classList.remove("visible");
setTimeout(() => node.remove(), 160);
};
const pause = () => {
if (dismissed || !startedAt) return;
remaining = Math.max(0, remaining - (performance.now() - startedAt));
startedAt = 0;
stopTimers();
renderCountdown();
};
const resume = () => {
if (dismissed || remaining <= 0) return dismiss();
stopTimers();
startedAt = performance.now();
renderCountdown();
timeout = setTimeout(() => dismiss(), remaining);
ticker = setInterval(() => {
if (!startedAt) return;
const elapsed = performance.now() - startedAt;
const visibleRemaining = Math.max(0, remaining - elapsed);
countdown.textContent = `${Math.max(1, Math.ceil(visibleRemaining / 1000))}s`;
}, 200);
};
close.addEventListener("click", (event) => {
function uploadRequest(path, formData, onProgress = null) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", apiPath(path), true);
xhr.withCredentials = true;
if (state.accountId) xhr.setRequestHeader("X-Active-Account", state.accountId);
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
if (typeof onProgress === "function") {
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && event.total > 0) onProgress(Math.round((event.loaded / event.total) * 100));
});
}
xhr.addEventListener("error", () => reject(new Error("Upload failed: network error")));
xhr.addEventListener("abort", () => reject(new Error("Upload cancelled")));
xhr.addEventListener("load", () => {
const contentType = xhr.getResponseHeader("content-type") || "";
let payload = xhr.responseText || "";
if (contentType.includes("application/json")) {
try { payload = payload ? JSON.parse(payload) : {}; } catch (_) { payload = {}; }
}
if (xhr.status < 200 || xhr.status >= 300) {
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Upload failed (${xhr.status})`;
const error = new Error(message);
error.status = xhr.status;
reject(error);
return;
}
resolve(payload);
});
xhr.send(formData);
});
}
function toast(message, kind = "info", duration = null, action = null) {
const normalized = ["success", "error", "warning", "info"].includes(kind) ? kind : "info";
const lifetime = Number(duration) > 0 ? Number(duration) : normalized === "error" ? 6500 : normalized === "warning" ? 5200 : 3800;
const node = el("div", `toast toast-${normalized}`);
node.setAttribute("role", normalized === "error" ? "alert" : "status");
const iconName = normalized === "success" ? "check-circle" : normalized === "error" || normalized === "warning" ? "circle-alert" : "info";
const icon = el("span", "toast-icon");
icon.innerHTML = svgIcon(iconName);
const text = el("span", "toast-message", message);
let actionButton = null;
if (action?.label && typeof action.onClick === "function") {
actionButton = el("button", "toast-action", action.label);
actionButton.type = "button";
}
const countdown = el("span", "toast-countdown");
countdown.setAttribute("aria-hidden", "true");
const close = el("button", "toast-close");
close.type = "button";
close.setAttribute("aria-label", "Dismiss notification");
close.innerHTML = svgIcon("x");
const controls = el("span", "toast-controls");
if (actionButton) controls.append(actionButton);
controls.append(close);
node.append(icon, text, countdown, controls);
const openDialogs = $$("dialog[open]");
let stack = $("#toast-stack");
if (openDialogs.length) {
const dialog = openDialogs[openDialogs.length - 1];
stack = dialog.querySelector(":scope > .modal-toast-stack");
if (!stack) {
stack = el("div", "toast-stack modal-toast-stack");
stack.setAttribute("aria-live", "polite");
dialog.append(stack);
}
}
stack.append(node);
requestAnimationFrame(() => node.classList.add("visible"));
let remaining = lifetime;
let startedAt = 0;
let timeout = null;
let ticker = null;
let dismissed = false;
const renderCountdown = () => { countdown.textContent = `${Math.max(1, Math.ceil(remaining / 1000))}s`; };
const stopTimers = () => { clearTimeout(timeout); clearInterval(ticker); timeout = null; ticker = null; };
const dismiss = (immediate = false) => {
if (dismissed) return;
dismissed = true;
stopTimers();
if (immediate) return node.remove();
node.classList.remove("visible");
setTimeout(() => node.remove(), 160);
};
const pause = () => {
if (dismissed || !startedAt) return;
remaining = Math.max(0, remaining - (performance.now() - startedAt));
startedAt = 0;
stopTimers();
renderCountdown();
};
const resume = () => {
if (dismissed || remaining <= 0) return dismiss();
stopTimers();
startedAt = performance.now();
renderCountdown();
timeout = setTimeout(() => dismiss(), remaining);
ticker = setInterval(() => {
if (!startedAt) return;
const elapsed = performance.now() - startedAt;
const visibleRemaining = Math.max(0, remaining - elapsed);
countdown.textContent = `${Math.max(1, Math.ceil(visibleRemaining / 1000))}s`;
}, 200);
};
close.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
dismiss(true);
});
if (actionButton) {
actionButton.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
dismiss(true);
try { await action.onClick(); }
catch (error) { toast(error?.message || "Action failed", "error"); }
});
if (actionButton) {
actionButton.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
dismiss(true);
try { await action.onClick(); }
catch (error) { toast(error?.message || "Action failed", "error"); }
});
}
node.addEventListener("mouseenter", pause);
node.addEventListener("mouseleave", resume);
node.addEventListener("focusin", pause);
node.addEventListener("focusout", (event) => { if (!node.contains(event.relatedTarget)) resume(); });
resume();
}
function progressToast(message) {
const node = el("div", "toast toast-info toast-progress");
node.setAttribute("role", "status");
const icon = el("span", "toast-icon");
icon.innerHTML = svgIcon("paperclip");
const content = el("span", "toast-progress-content");
const text = el("span", "toast-message", message);
const track = el("span", "toast-progress-track");
const bar = el("span", "toast-progress-bar");
track.append(bar);
content.append(text, track);
const value = el("span", "toast-progress-value", "0%");
const close = el("button", "toast-close");
close.type = "button";
close.setAttribute("aria-label", "Dismiss notification");
close.innerHTML = svgIcon("x");
let stack = $("#toast-stack");
const openDialogs = $$("dialog[open]");
if (openDialogs.length) {
const dialog = openDialogs[openDialogs.length - 1];
stack = dialog.querySelector(":scope > .modal-toast-stack");
if (!stack) {
stack = el("div", "toast-stack modal-toast-stack");
stack.setAttribute("aria-live", "polite");
dialog.append(stack);
}
node.addEventListener("mouseenter", pause);
node.addEventListener("mouseleave", resume);
node.addEventListener("focusin", pause);
node.addEventListener("focusout", (event) => { if (!node.contains(event.relatedTarget)) resume(); });
resume();
}
node.append(icon, content, value, close);
stack.append(node);
requestAnimationFrame(() => node.classList.add("visible"));
let dismissed = false;
const dismiss = (delay = 0) => {
if (dismissed) return;
dismissed = true;
setTimeout(() => {
node.classList.remove("visible");
setTimeout(() => node.remove(), 160);
}, Math.max(0, delay));
};
close.addEventListener("click", () => dismiss());
return {
update(percent, label = null) {
const normalized = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
bar.style.width = `${normalized}%`;
value.textContent = `${normalized}%`;
if (label) text.textContent = label;
},
complete(label = trText("Upload complete")) {
bar.style.width = "100%";
value.textContent = "100%";
text.textContent = label;
node.classList.remove("toast-info");
node.classList.add("toast-success");
icon.innerHTML = svgIcon("check-circle");
dismiss(1200);
},
fail(label) {
text.textContent = label || trText("Upload failed");
node.classList.remove("toast-info");
node.classList.add("toast-error");
icon.innerHTML = svgIcon("circle-alert");
dismiss(4800);
},
dismiss,
};
}
function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) {
return new Promise((resolve) => {
const dialog = el("dialog", "dialog app-prompt-dialog");
const form = el("form", "dialog-card app-prompt-card");
form.method = "dialog";
const header = el("header", "dialog-header");
header.append(el("strong", "", title));
const close = el("button", "icon-button");
close.type = "button";
close.setAttribute("aria-label", cancelLabel);
close.innerHTML = svgIcon("x");
header.append(close);
const body = el("div", "app-prompt-body");
if (message) body.append(el("p", "app-prompt-message", message));
let control = null;
if (Array.isArray(selectOptions)) {
control = document.createElement("select");
control.className = "app-prompt-control";
for (const option of selectOptions) control.add(new Option(option.label ?? option.value, option.value));
if (input?.value !== undefined) control.value = String(input.value);
body.append(control);
} else if (input) {
control = input.multiline ? document.createElement("textarea") : document.createElement("input");
control.className = "app-prompt-control";
if (!input.multiline) control.type = input.type || "text";
control.value = input.value ?? "";
if (input.placeholder) control.placeholder = input.placeholder;
if (input.required) control.required = true;
body.append(control);
}
const actions = el("footer", "dialog-actions app-prompt-actions");
const cancel = el("button", "button quiet", cancelLabel);
cancel.type = "button";
const confirm = el("button", danger ? "button danger" : "button primary", confirmLabel);
confirm.type = "submit";
actions.append(cancel, confirm);
form.append(header, body, actions);
dialog.append(form);
document.body.append(dialog);
function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) {
return new Promise((resolve) => {
const dialog = el("dialog", "dialog app-prompt-dialog");
const form = el("form", "dialog-card app-prompt-card");
form.method = "dialog";
const header = el("header", "dialog-header");
header.append(el("strong", "", title));
const close = el("button", "icon-button");
close.type = "button";
close.setAttribute("aria-label", cancelLabel);
close.innerHTML = svgIcon("x");
header.append(close);
const body = el("div", "app-prompt-body");
if (message) body.append(el("p", "app-prompt-message", message));
let control = null;
if (Array.isArray(selectOptions)) {
control = document.createElement("select");
control.className = "app-prompt-control";
for (const option of selectOptions) control.add(new Option(option.label ?? option.value, option.value));
if (input?.value !== undefined) control.value = String(input.value);
body.append(control);
} else if (input) {
control = input.multiline ? document.createElement("textarea") : document.createElement("input");
control.className = "app-prompt-control";
if (!input.multiline) control.type = input.type || "text";
control.value = input.value ?? "";
if (input.placeholder) control.placeholder = input.placeholder;
if (input.required) control.required = true;
body.append(control);
}
const actions = el("footer", "dialog-actions app-prompt-actions");
const cancel = el("button", "button quiet", cancelLabel);
cancel.type = "button";
const confirm = el("button", danger ? "button danger" : "button primary", confirmLabel);
confirm.type = "submit";
actions.append(cancel, confirm);
form.append(header, body, actions);
dialog.append(form);
document.body.append(dialog);
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
resolve(value);
if (dialog.open) dialog.close();
else dialog.remove();
};
close.addEventListener("click", () => finish(null));
cancel.addEventListener("click", () => finish(null));
dialog.addEventListener("cancel", (event) => { event.preventDefault(); finish(null); });
dialog.addEventListener("close", () => { if (!settled) { settled = true; resolve(null); } dialog.remove(); }, { once: true });
form.addEventListener("submit", (event) => {
event.preventDefault();
if (control && !control.reportValidity()) return;
finish(control ? control.value : true);
});
dialog.showModal();
setTimeout(() => (control || confirm).focus(), 20);
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
resolve(value);
if (dialog.open) dialog.close();
else dialog.remove();
};
close.addEventListener("click", () => finish(null));
cancel.addEventListener("click", () => finish(null));
dialog.addEventListener("cancel", (event) => { event.preventDefault(); finish(null); });
dialog.addEventListener("close", () => { if (!settled) { settled = true; resolve(null); } dialog.remove(); }, { once: true });
form.addEventListener("submit", (event) => {
event.preventDefault();
if (control && !control.reportValidity()) return;
finish(control ? control.value : true);
});
}
dialog.showModal();
setTimeout(() => (control || confirm).focus(), 20);
});
}
async function appConfirm(message, { title = "Confirm action", confirmLabel = "Continue", danger = false } = {}) {
return (await appDialog({ title, message, confirmLabel, danger })) === true;
}
async function appConfirm(message, { title = "Confirm action", confirmLabel = "Continue", danger = false } = {}) {
return (await appDialog({ title, message, confirmLabel, danger })) === true;
}
function appPrompt(title, { message = "", value = "", placeholder = "", type = "text", confirmLabel = "OK", required = false } = {}) {
return appDialog({ title, message, input: { value, placeholder, type, required }, confirmLabel });
}
function appPrompt(title, { message = "", value = "", placeholder = "", type = "text", confirmLabel = "OK", required = false } = {}) {
return appDialog({ title, message, input: { value, placeholder, type, required }, confirmLabel });
}
function appSelect(title, options, { message = "", value = "", confirmLabel = "Choose" } = {}) {
return appDialog({ title, message, input: { value }, selectOptions: options, confirmLabel });
}
function appSelect(title, options, { message = "", value = "", confirmLabel = "Choose" } = {}) {
return appDialog({ title, message, input: { value }, selectOptions: options, confirmLabel });
}
function setBusy(button, busy) {
if (!button) return;
button.disabled = busy;
button.setAttribute("aria-busy", String(busy));
}
function setBusy(button, busy) {
if (!button) return;
button.disabled = busy;
button.setAttribute("aria-busy", String(busy));
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1153 -1072
View File
File diff suppressed because it is too large. Load diff
+5 -3
View File
@@ -455,7 +455,7 @@ function showContextMenu(x, y, items) {
menu.replaceChildren();
for (const item of items) {
if (item === "separator") { menu.append(el("div", "separator")); continue; }
const button = el("button", item.danger ? "danger" : "", item.label);
const button = el("button", item.danger ? "danger" : "", trText(item.label));
button.type = "button";
button.addEventListener("click", async () => { hideContextMenu(); await item.action(); });
menu.append(button);
@@ -476,7 +476,7 @@ async function ensureMessageOpen(message) {
async function promptFolder(label, exclude = null) {
const options = orderedFolders().map((folder) => folder.name).filter((name) => name !== exclude);
if (!options.length) return null;
return appSelect(label, options.map((name) => ({ value: name, label: name })), { value: options[0], confirmLabel: "Choose folder" });
return appSelect(trText(label), options.map((name) => ({ value: name, label: name })), { value: options[0], confirmLabel: trText("Choose folder") });
}
function openMessageContextMenu(x, y, message) {
@@ -735,6 +735,7 @@ async function showMessageSource() {
const source = await blob.text();
const dialog = document.createElement("dialog");
dialog.className = "dialog";
dialog.dataset.i18nScope = "";
const card = el("div", "dialog-card source-card");
const header = el("header", "dialog-header");
header.append(el("strong", "", "Message source"));
@@ -747,6 +748,7 @@ async function showMessageSource() {
const download = el("button", "button primary", "Download .eml"); download.type = "button"; download.addEventListener("click", downloadEml);
actions.append(copy, download);
card.append(header, pre, actions); dialog.append(card); document.body.append(dialog);
translateScopedUiText(state.prefs.language);
dialog.addEventListener("close", () => dialog.remove(), { once: true });
dialog.showModal();
} catch (error) { toast(error.message, "error"); }
@@ -813,7 +815,7 @@ async function forwardAsAttachment() {
try {
const blob = await fetchEmlBlob(detail);
openCompose("new");
$("#compose-title").textContent = "Forward as attachment";
$("#compose-title").textContent = trText("Forward as attachment");
$("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`;
const safe = String(detail.subject || `message-${detail.uid}`).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120) || `message-${detail.uid}`;
const file = new File([blob], `${safe}.eml`, { type: "message/rfc822" });
+27 -10
View File
@@ -2,29 +2,46 @@
async function loadQuota() {
const summary = $("#quota-summary span");
const bar = $("#quota-bar");
if (!summary || !bar) return;
summary.textContent = "Loading quota…";
bar.style.width = "0%";
const sidebar = $("#folder-quota");
const sidebarText = $("#folder-quota-text");
const sidebarBar = $("#folder-quota-bar");
if (!summary && !sidebarText) return;
if (summary) summary.textContent = tr("loading_quota", "Loading quota…");
if (bar) bar.style.width = "0%";
if (sidebarText) sidebarText.textContent = tr("loading", "Loading…");
if (sidebarBar) sidebarBar.style.width = "0%";
const setLevel = (level) => {
for (const box of [$("#quota-summary"), sidebar]) {
box?.classList.toggle("quota-warning", level === "warning");
box?.classList.toggle("quota-danger", level === "danger");
}
};
try {
const quota = await request("/quota", { method: "GET" });
const used = Number(quota.usage_bytes || 0);
const limit = Number(quota.limit_bytes || 0);
if (limit > 0) {
const percent = Math.max(0, Math.min(100, (used / limit) * 100));
summary.textContent = `${formatBytes(used)} of ${formatBytes(limit)} (${percent.toFixed(1)}%)`;
bar.style.width = `${percent}%`;
const quotaBox = $("#quota-summary");
if (summary) summary.textContent = `${formatBytes(used)} ${tr("of", "of")} ${formatBytes(limit)} (${percent.toFixed(1)}%)`;
if (bar) bar.style.width = `${percent}%`;
if (sidebarText) sidebarText.textContent = `${percent.toFixed(0)}% · ${formatBytes(used)} / ${formatBytes(limit)}`;
if (sidebarBar) sidebarBar.style.width = `${percent}%`;
const level = percent >= 95 ? "danger" : percent >= 80 ? "warning" : "normal";
quotaBox?.classList.toggle("quota-warning", level === "warning");
quotaBox?.classList.toggle("quota-danger", level === "danger");
setLevel(level);
if (level !== "normal" && state.quotaWarningLevel !== `${state.accountId}:${level}`) {
state.quotaWarningLevel = `${state.accountId}:${level}`;
toast(level === "danger" ? `Mailbox storage is ${percent.toFixed(0)}% full` : `Mailbox storage is ${percent.toFixed(0)}% used`, level === "danger" ? "error" : "warning");
}
} else {
summary.textContent = `${formatBytes(used)} used · server limit unavailable`;
if (summary) summary.textContent = `${formatBytes(used)} ${tr("used", "used")} · ${tr("server_limit_unavailable", "server limit unavailable")}`;
if (sidebarText) sidebarText.textContent = `${formatBytes(used)} ${tr("used", "used")}`;
setLevel("normal");
}
} catch (error) { summary.textContent = `Quota unavailable: ${error.message}`; }
} catch (error) {
if (summary) summary.textContent = `${tr("quota_unavailable", "Quota unavailable")}: ${error.message}`;
if (sidebarText) sidebarText.textContent = tr("unavailable", "Unavailable");
setLevel("normal");
}
}
function openSettings(tab = "appearance") {
+64 -10
View File
@@ -61,7 +61,41 @@ function bindPaneResizer(selector, preferenceKey, minPx, maxPx) {
});
}
function positionToolbarMenu(menu) {
if (!menu?.open) return;
const popover = menu.querySelector(".toolbar-menu-popover");
const summary = menu.querySelector("summary");
if (!popover || !summary) return;
if (matchMedia("(max-width: 560px)").matches) {
Object.assign(popover.style, { left: "8px", right: "8px", top: "auto", bottom: "8px" });
return;
}
Object.assign(popover.style, { right: "auto", bottom: "auto" });
const rect = summary.getBoundingClientRect();
const width = Math.max(164, popover.offsetWidth || 164);
const height = popover.offsetHeight || 80;
const margin = 8;
let left = Math.min(rect.left, window.innerWidth - width - margin);
left = Math.max(margin, left);
let top = rect.bottom + 5;
if (top + height > window.innerHeight - margin) top = Math.max(margin, rect.top - height - 5);
popover.style.left = `${Math.round(left)}px`;
popover.style.top = `${Math.round(top)}px`;
}
function bindToolbarMenus() {
const menus = $$(".toolbar-menu");
menus.forEach((menu) => menu.addEventListener("toggle", () => {
if (!menu.open) return;
menus.forEach((other) => { if (other !== menu) other.open = false; });
requestAnimationFrame(() => positionToolbarMenu(menu));
}));
window.addEventListener("resize", () => menus.forEach(positionToolbarMenu), { passive: true });
document.addEventListener("scroll", () => menus.forEach(positionToolbarMenu), true);
}
function bindEvents() {
bindToolbarMenus();
bindPaneResizer("#folder-resizer", "folderPaneWidth", 170, 420);
bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760);
bindRecipientAutocomplete("#compose-to");
@@ -95,20 +129,23 @@ function bindEvents() {
setComposeMode(next, true);
insertIdentitySignature(editor);
scheduleDraftSave();
updateComposeMessageSize();
});
$("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor")); updateComposePgpUi(true); });
$("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor")); updateComposePgpUi(true); updateComposeMessageSize(); });
["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("input", () => {
state.pgpRecipientStatus = [];
updateComposePgpUi();
scheduleDraftSave();
updateComposeMessageSize();
}));
$("#compose-subject").addEventListener("input", scheduleDraftSave);
$("#compose-subject").addEventListener("input", () => { scheduleDraftSave(); updateComposeMessageSize(); });
["#compose-priority", "#compose-mdn", "#compose-pgp-mode", "#compose-pgp-inline", "#compose-pgp-attach-key", "#compose-pgp-attach-recipient-keys"].forEach((selector) => $(selector).addEventListener("change", async () => {
if (selector === "#compose-pgp-attach-recipient-keys" && $(selector).checked) await checkComposePgpRecipients(false, composePgpVisibleRecipients());
updateComposePgpUi();
scheduleDraftSave();
updateComposeMessageSize();
}));
$("#compose-editor").addEventListener("input", () => { if (state.composeSelectedImage && !state.composeSelectedImage.isConnected) setSelectedInlineImage(null); scheduleDraftSave(); scheduleUnusedInlineAttachmentCleanup(); });
$("#compose-editor").addEventListener("input", () => { if (state.composeSelectedImage && !state.composeSelectedImage.isConnected) setSelectedInlineImage(null); scheduleDraftSave(); scheduleUnusedInlineAttachmentCleanup(); updateComposeMessageSize(); });
$("#compose-editor").addEventListener("click", (event) => {
const image = event.target.closest?.("img[data-rustbox-cid]");
setSelectedInlineImage(image || null);
@@ -145,8 +182,16 @@ function bindEvents() {
$("#flag-btn").addEventListener("click", toggleFlagged);
$("#archive-btn").addEventListener("click", archiveCurrent);
$("#spam-btn").addEventListener("click", junkCurrent);
$("#move-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await moveCurrentTo(target); });
$("#copy-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await copyCurrentTo(target); });
$("#reader-move-action").addEventListener("click", async () => {
$("#message-transfer-menu").open = false;
const target = await promptFolder("Move to folder", state.currentDetail?.folder || state.folder);
if (target) await moveCurrentTo(target);
});
$("#reader-copy-action").addEventListener("click", async () => {
$("#message-transfer-menu").open = false;
const target = await promptFolder("Copy to folder", state.currentDetail?.folder || state.folder);
if (target) await copyCurrentTo(target);
});
$("#bulk-select-all").addEventListener("change", (event) => { const source = state.searchAllResults || state.messages; for (const message of source) { const key = messageKey(message); event.target.checked ? state.selectedMessages.add(key) : state.selectedMessages.delete(key); } renderMessages(); });
$("#bulk-read").addEventListener("click", () => performBulk("read"));
$("#bulk-unread").addEventListener("click", () => performBulk("unread"));
@@ -221,11 +266,13 @@ function bindEvents() {
$("#rename-folder-btn").addEventListener("click", renameFolder);
$("#delete-folder-btn").addEventListener("click", deleteFolder);
$("#subscribe-folder-btn").addEventListener("click", toggleCurrentFolderSubscription);
$("#headers-btn").addEventListener("click", showHeaders);
$("#source-btn").addEventListener("click", showMessageSource);
$("#headers-btn").addEventListener("click", () => { $("#message-details-menu").open = false; showHeaders(); });
$("#source-btn").addEventListener("click", async () => { $("#message-details-menu").open = false; await showMessageSource(); });
$("#quick-filter-btn").addEventListener("click", () => createFilterFromCurrentMessage("sender"));
$("#reader-from").addEventListener("click", (event) => { const rect = event.currentTarget.getBoundingClientRect(); openCurrentSenderMenu(rect.left, rect.bottom + 4); });
$("#reader-from").addEventListener("keydown", (event) => { if (["Enter", " "].includes(event.key)) { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); openCurrentSenderMenu(rect.left, rect.bottom + 4); } });
$("#reader-to").addEventListener("click", toggleReaderAddressExpansion);
$("#reader-to").addEventListener("keydown", (event) => { if (["Enter", " "].includes(event.key)) { event.preventDefault(); toggleReaderAddressExpansion(); } });
$("#print-btn").addEventListener("click", printCurrentMessage);
$("#pgp-reader-process").addEventListener("click", processCurrentPgpMessage);
$("#pgp-reader-discover").addEventListener("click", discoverCurrentPgpSigner);
@@ -250,6 +297,7 @@ function bindEvents() {
$$(".settings-tab").forEach((button) => button.addEventListener("click", () => { selectSettingsTab(button.dataset.tab); if (button.dataset.tab === "sieve") loadSieve(); if (button.dataset.tab === "identities") { loadIdentities(); loadOpenPgp(); } if (button.dataset.tab === "openpgp") loadOpenPgp(); if (button.dataset.tab === "notifications") loadNotificationPreferences(); if (button.dataset.tab === "mailbox") loadMailPreferences(); if (button.dataset.tab === "composing") loadResponseTemplates(); if (button.dataset.tab === "diagnostics") loadDiagnostics(); if (button.dataset.tab === "account") { renderAccountSettings(); loadActiveSessions(); } if (button.dataset.tab === "folders") { loadSpecialFolders(); loadQuota(); } }));
$("#pref-theme").addEventListener("change", (event) => savePreference("theme", event.target.value));
$("#pref-interface-skin").addEventListener("change", (event) => savePreference("interfaceSkin", event.target.value));
$("#pref-preset-scale-unlocked").addEventListener("change", (event) => savePreference("presetScaleUnlocked", event.target.checked));
$("#pref-density").addEventListener("change", (event) => savePreference("density", event.target.value));
$("#pref-preview").addEventListener("change", (event) => savePreference("preview", event.target.value));
$("#pref-scale").addEventListener("change", (event) => savePreference("scale", event.target.value));
@@ -396,7 +444,10 @@ function bindEvents() {
$("#sieve-deactivate").addEventListener("click", deactivateSieve);
$("#sieve-delete").addEventListener("click", deleteSieve);
bindEditor();
document.addEventListener("click", (event) => { if (!event.target.closest("#context-menu")) hideContextMenu(); });
document.addEventListener("click", (event) => {
if (!event.target.closest("#context-menu")) hideContextMenu();
if (!event.target.closest(".toolbar-menu")) $$(".toolbar-menu[open]").forEach((menu) => { menu.open = false; });
});
window.addEventListener("blur", hideContextMenu);
document.addEventListener("scroll", hideContextMenu, true);
document.addEventListener("keydown", (event) => {
@@ -438,7 +489,10 @@ function bindEvents() {
else closeReader();
}
});
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { if (state.prefs.theme === "system") applyPreferences(); });
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
if (!$("#login-view").classList.contains("hidden")) showLogin();
else if (state.prefs.theme === "system") applyPreferences();
});
window.addEventListener("resize", () => { if (innerWidth > 820) setFolderDrawerOpen(false); });
}
@@ -447,7 +501,7 @@ async function boot() {
bindEvents();
await loadAuthPolicy();
const authenticated = await loadAccounts();
if (authenticated) { await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime(); } else { await loadTranslations(state.prefs.language); }
if (authenticated) { await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime(); } else { await loadTranslations(state.prefs.language); }
}
boot();