1 parent
d55640d37a
commit
fff8521a17
67 files changed
+11016
-16763
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user