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

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 19:28:47 +01:00
1 parent d55640d37a
commit fff8521a17
67 files changed
+11016 -16763

No files matched your search

+342
View File
@@ -0,0 +1,342 @@
/* RustBox UI module: 80-settings-realtime.js */
async function loadQuota() {
const summary = $("#quota-summary span");
const bar = $("#quota-bar");
if (!summary || !bar) return;
summary.textContent = "Loading quota…";
bar.style.width = "0%";
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");
const level = percent >= 95 ? "danger" : percent >= 80 ? "warning" : "normal";
quotaBox?.classList.toggle("quota-warning", level === "warning");
quotaBox?.classList.toggle("quota-danger", level === "danger");
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`;
}
} catch (error) { summary.textContent = `Quota unavailable: ${error.message}`; }
}
function openSettings(tab = "appearance") {
selectSettingsTab(tab);
$("#settings-dialog").showModal();
if (tab === "sieve") loadSieve();
if (tab === "identities") { loadIdentities(); loadOpenPgp(); }
if (tab === "openpgp") loadOpenPgp();
if (tab === "notifications") loadNotificationPreferences();
if (tab === "mailbox") loadMailPreferences();
if (tab === "diagnostics") loadDiagnostics();
if (tab === "account") { renderAccountSettings(); loadActiveSessions(); }
if (tab === "folders") { loadSpecialFolders(); loadQuota(); }
}
function selectSettingsTab(name) {
$$(".settings-tab").forEach((button) => button.classList.toggle("active", button.dataset.tab === name));
$$(".settings-panel").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.panel !== name));
}
function updateNotificationPermissionStatus() {
const status = $("#notify-permission-status");
if (!status) return;
status.textContent = !("Notification" in window) ? "Browser permission: unsupported" : `Browser permission: ${Notification.permission}`;
}
async function loadNotificationPreferences() {
try {
const prefs = await request("/settings/notifications", { method: "GET" });
state.notificationPrefs = { ...state.notificationPrefs, ...prefs };
$("#notify-enabled").checked = Boolean(prefs.enabled);
$("#notify-sound").checked = Boolean(prefs.sound);
$("#notify-dnd-enabled").checked = Boolean(prefs.dnd_enabled);
$("#notify-dnd-start").value = prefs.dnd_start || "22:00";
$("#notify-dnd-end").value = prefs.dnd_end || "07:00";
renderNotificationFolders(prefs.folders || ["INBOX"]);
updateNotificationPermissionStatus();
} catch (error) { toast(error.message, "error"); }
}
function renderNotificationFolders(selected) {
const set = new Set(selected || []);
const list = $("#notify-folders");
list.replaceChildren();
for (const folder of orderedFolders()) {
const label = el("label", "checkbox-row");
const input = document.createElement("input");
input.type = "checkbox";
input.value = folder.name;
input.checked = set.has(folder.name);
label.append(input, el("span", "", folder.name));
list.append(label);
}
}
async function saveNotificationPreferences() {
const folders = $$('#notify-folders input[type="checkbox"]:checked').map((input) => input.value);
try {
if ($("#notify-enabled").checked && "Notification" in window && Notification.permission === "default") await requestNotificationPermission();
const prefs = await request("/settings/notifications", { method: "PUT", body: JSON.stringify({ enabled: $("#notify-enabled").checked, sound: $("#notify-sound").checked, folders, dnd_enabled: $("#notify-dnd-enabled").checked, dnd_start: $("#notify-dnd-start").value || "22:00", dnd_end: $("#notify-dnd-end").value || "07:00" }) });
state.notificationPrefs = { ...state.notificationPrefs, ...prefs };
updateNotificationPermissionStatus();
toast("Notification settings saved");
} catch (error) { toast(error.message, "error"); }
}
async function requestNotificationPermission() {
if (!("Notification" in window)) return toast("Notifications are not supported by this browser", "error");
const result = await Notification.requestPermission();
updateNotificationPermissionStatus();
toast(`Notification permission: ${result}`, result === "granted" ? "success" : "warning");
return result;
}
async function testNotification() {
if (!("Notification" in window)) return toast("Notifications are not supported by this browser", "error");
let permission = Notification.permission;
if (permission === "default") permission = await requestNotificationPermission();
if (permission !== "granted") return toast("Browser notifications are not allowed", "warning");
const notification = new Notification("RustBox notification test", { body: "Browser notifications are working.", tag: "rustbox-test" });
notification.onclick = () => { window.focus(); notification.close(); };
if ($("#notify-sound")?.checked) playNotificationSound();
toast("Test notification sent", "success");
}
function dndActive(prefs = state.notificationPrefs) {
if (!prefs?.dnd_enabled) return false;
const toMinutes = (value) => {
const [h, m] = String(value || "00:00").split(":").map(Number);
return h * 60 + m;
};
const now = new Date();
const current = now.getHours() * 60 + now.getMinutes();
const start = toMinutes(prefs.dnd_start);
const end = toMinutes(prefs.dnd_end);
if (start === end) return true;
return start < end ? current >= start && current < end : current >= start || current < end;
}
function playNotificationSound() {
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return;
const context = new AudioContext();
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.frequency.value = 620;
gain.gain.setValueAtTime(0.0001, context.currentTime);
gain.gain.exponentialRampToValueAtTime(0.035, context.currentTime + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.16);
oscillator.connect(gain); gain.connect(context.destination);
oscillator.start(); oscillator.stop(context.currentTime + 0.17);
oscillator.onended = () => context.close().catch(() => { });
} catch (_) { }
}
function notifyNewMail(data, accountId) {
const prefs = state.notificationPrefs || {};
if (!prefs.enabled || dndActive(prefs)) return;
const folders = prefs.folders || [];
if (folders.length && !folders.includes(data.folder)) return;
if ("Notification" in window && Notification.permission === "granted") {
const account = state.accounts.find((item) => item.id === accountId);
const title = data.latest_sender || account?.email || "New mail";
const body = data.latest_subject || `${data.count || 1} new message(s) in ${data.folder}`;
const notification = new Notification(title, { body, tag: `rustbox-${accountId}-${data.folder}`, renotify: true });
notification.onclick = () => { window.focus(); notification.close(); };
}
if (prefs.sound) playNotificationSound();
}
function calendarReminderStorageKey(event) {
return `rustbox.calendarReminder.${state.accountId || "account"}.${event.id}.${event.start_time}`;
}
function fireCalendarReminder(event) {
const title = event.title || "Calendar reminder";
const location = event.location ? ` · ${event.location}` : "";
const when = formatDate(event.start_time, true);
toast(`${title} — ${when}${location}`, "info", 12000);
if (!dndActive() && state.notificationPrefs?.enabled && "Notification" in window && Notification.permission === "granted") {
const notification = new Notification(title, { body: `${when}${location}`, tag: `rustbox-calendar-${event.id}-${event.start_time}`, renotify: true });
notification.onclick = () => { window.focus(); notification.close(); openCalendar(); };
}
if (!dndActive() && state.notificationPrefs?.sound) playNotificationSound();
}
async function checkCalendarReminders() {
if (!state.accountId) return;
const now = Date.now();
const start = new Date(now - 2 * 24 * 60 * 60 * 1000);
const end = new Date(now + 2 * 24 * 60 * 60 * 1000);
try {
const data = await request(`/calendar/events?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`, { method: "GET" });
for (const event of data.events || []) {
if (event.reminder_minutes == null || String(event.status || "").toLowerCase() === "cancelled") continue;
const startMs = new Date(event.start_time).getTime();
if (!Number.isFinite(startMs)) continue;
const trigger = startMs - Math.max(0, Number(event.reminder_minutes) || 0) * 60000;
if (trigger > now || now - trigger > 10 * 60 * 1000) continue;
const key = calendarReminderStorageKey(event);
if (localStorage.getItem(key)) continue;
localStorage.setItem(key, String(now));
fireCalendarReminder(event);
}
} catch (_) { }
}
function startCalendarReminderWatcher() {
if (state.calendarReminderTimer) clearInterval(state.calendarReminderTimer);
void checkCalendarReminders();
state.calendarReminderTimer = setInterval(() => { void checkCalendarReminders(); }, 30000);
}
function scheduleRealtimeRefresh(event, accountId) {
if (accountId !== state.accountId) return;
clearTimeout(state.realtimeRefreshTimer);
state.realtimeRefreshTimer = setTimeout(async () => {
await refreshFolderListOnly();
if (["NewMessages", "FlagsChanged"].includes(event.type) && event.data?.folder === state.folder) {
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true);
}
}, 250);
}
function connectRealtime() {
if (!state.accounts.length) return;
if (state.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(state.ws.readyState)) return;
clearTimeout(state.wsRetryTimer);
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${location.host}${BASE}/api/ws`);
state.ws = socket;
socket.onmessage = (message) => {
try {
const payload = JSON.parse(message.data);
const event = payload.event || {};
if (event.type === "NewMessages") notifyNewMail(event.data || {}, payload.accountId);
if (["NewMessages", "FlagsChanged", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId);
} catch (error) { console.warn("Invalid realtime event", error); }
};
socket.onclose = () => {
if (state.ws === socket) state.ws = null;
if (state.accounts.length) state.wsRetryTimer = setTimeout(connectRealtime, 2500);
};
socket.onerror = () => socket.close();
}
function disconnectRealtime() {
clearTimeout(state.wsRetryTimer);
clearTimeout(state.realtimeRefreshTimer);
state.wsRetryTimer = null;
state.realtimeRefreshTimer = null;
const socket = state.ws;
state.ws = null;
if (socket && socket.readyState < WebSocket.CLOSING) socket.close();
}
function capabilityBadge(label, supported) {
return `<span class="capability-badge ${supported ? "supported" : "unsupported"}">${escapeHtml(label)}<strong>${supported ? "yes" : "no"}</strong></span>`;
}
async function loadDiagnostics() {
const imapStatus = $("#diagnostic-imap-status");
const smtpStatus = $("#diagnostic-smtp-status");
const sieveStatus = $("#diagnostic-sieve-status");
const wsStatus = $("#diagnostic-ws-status");
const syncStatus = $("#diagnostic-sync-status");
const searchStatus = $("#diagnostic-search-status");
imapStatus.textContent = "Checking";
smtpStatus.textContent = "Checking";
sieveStatus.textContent = "Checking";
syncStatus.textContent = "Checking";
searchStatus.textContent = "Checking";
wsStatus.textContent = state.ws?.readyState === WebSocket.OPEN ? "Connected" : "Disconnected";
try {
const info = await request("/diagnostics/imap", { method: "GET" });
imapStatus.textContent = info.connected ? "Connected" : "Unavailable";
$("#diagnostic-imap-summary").innerHTML = [
["IDLE", info.idle], ["MOVE", info.moveExtension], ["UIDPLUS", info.uidplus], ["CONDSTORE", info.condstore],
["QRESYNC", info.qresync], ["SORT", info.sort], ["THREAD", info.thread], ["NAMESPACE", info.namespace],
["SPECIAL-USE", info.specialUse], ["QUOTA", info.quota], ["ACL", info.acl], ["LIST-EXTENDED", info.listExtended], ["UTF8=ACCEPT", info.utf8Accept], ["CUSTOM KEYWORDS", info.customKeywords],
].map(([label, supported]) => capabilityBadge(label, supported)).join("");
$("#diagnostic-imap-caps").textContent = (info.capabilities || []).join(" · ") || "No capabilities reported";
} catch (error) {
imapStatus.textContent = "Unavailable";
$("#diagnostic-imap-summary").textContent = error.message;
$("#diagnostic-imap-caps").textContent = "";
}
try {
const info = await request("/diagnostics/smtp", { method: "GET" });
smtpStatus.textContent = info.connected ? "Connected" : "Unavailable";
$("#diagnostic-smtp-summary").textContent = info.connected ? (info.secureTransport ? "Authenticated connection · TLS enabled" : "Authenticated connection · plaintext transport") : "SMTP unavailable";
} catch (error) {
smtpStatus.textContent = "Unavailable";
$("#diagnostic-smtp-summary").textContent = error.message;
}
try {
const info = await request("/sieve/status", { method: "GET" });
sieveStatus.textContent = info.connected ? "Connected" : "Unavailable";
$("#diagnostic-sieve-summary").textContent = `${info.tlsMode || ""}${info.implementation ? ` · ${info.implementation}` : ""}${info.extensions?.length ? ` · ${info.extensions.join(", ")}` : ""}`;
} catch (error) {
sieveStatus.textContent = "Unavailable";
$("#diagnostic-sieve-summary").textContent = error.message;
}
try {
const info = await request("/diagnostics/sync", { method: "GET" });
syncStatus.textContent = info.unsyncedFolders ? "Partial" : "Ready";
const folderSync = info.latestFolderListSync ? formatDate(info.latestFolderListSync, true) : "never";
const messageSync = info.latestMessageSync ? formatDate(info.latestMessageSync, true) : "never";
$("#diagnostic-sync-summary").textContent = `${info.cachedFolders} cached folders · ${info.cachedMessages} cached messages · folder list ${folderSync} · messages ${messageSync}${info.unsyncedFolders ? ` · ${info.unsyncedFolders} folders awaiting message sync` : ""} · ${info.idleTasks || 0} IDLE task(s) / ${info.idleSubscribers || 0} subscriber(s)`;
} catch (error) {
syncStatus.textContent = "Unavailable";
$("#diagnostic-sync-summary").textContent = error.message;
}
try {
const info = await request("/diagnostics/search", { method: "GET" });
searchStatus.textContent = "Ready";
$("#diagnostic-search-summary").textContent = `${info.indexedDocuments} indexed · ${info.cachedMessages} cached messages · ${info.cachedBodies} cached bodies${info.excludedMessages ? ` · ${info.excludedMessages} excluded` : ""}`;
} catch (error) {
searchStatus.textContent = "Unavailable";
$("#diagnostic-search-summary").textContent = error.message;
}
}
async function reconnectImap() {
const button = $("#diagnostic-imap-reconnect");
if (button) button.disabled = true;
try {
const result = await request("/diagnostics/imap/reconnect", { method: "POST", body: "{}" });
toast(`IMAP reconnected · ${result.capabilityCount || 0} capabilities`);
await loadDiagnostics();
await loadFolders(false);
} catch (error) {
toast(error.message, "error");
} finally {
if (button) button.disabled = false;
}
}
async function rebuildSearchIndex() {
const button = $("#diagnostic-search-rebuild");
if (!await appConfirm("Rebuild the search index from the local message cache?", { title: "Rebuild search index", confirmLabel: "Rebuild" })) return;
button.disabled = true;
try {
const result = await request("/diagnostics/search/rebuild", { method: "POST", body: "{}" });
toast(`Search index rebuilt: ${result.indexedDocuments} messages`);
await loadDiagnostics();
} catch (error) {
toast(error.message, "error");
} finally {
button.disabled = false;
}
}