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

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 12:06:44 +01:00
1 parent 96a32ea8fb
commit a643e7e3ab
106 files changed
+2223 -4669

No files matched your search

+117 -8
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 80-settings-realtime.js */
async function loadQuota() {
const summary = $("#quota-summary span");
const bar = $("#quota-bar");
@@ -158,17 +158,33 @@ function playNotificationSound() {
} catch (_) { }
}
async function openRealtimeNotificationTarget(accountId, data) {
window.focus();
const folder = data?.folder || "INBOX";
const uid = Number(data?.latest_uid);
if (accountId && accountId !== state.accountId) await switchAccount(accountId);
if (folder !== state.folder) {
state.folder = folder;
renderFolders();
await loadMessages(true);
}
if (Number.isFinite(uid) && uid > 0) await openMessage(folder, uid);
}
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;
const uid = Number(data.latest_uid);
const alreadyOpen = Number(data.count || 1) === 1 && accountId === state.accountId && data.folder === state.currentDetail?.folder && Number.isFinite(uid) && uid === Number(state.currentDetail?.uid) && document.visibilityState === "visible" && document.hasFocus();
if (alreadyOpen) 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(); };
notification.onclick = () => { notification.close(); void openRealtimeNotificationTarget(accountId, data); };
}
if (prefs.sound) playNotificationSound();
}
@@ -216,48 +232,141 @@ function startCalendarReminderWatcher() {
state.calendarReminderTimer = setInterval(() => { void checkCalendarReminders(); }, 30000);
}
function realtimeEventKey(payload) {
if (payload.eventId) return String(payload.eventId);
const event = payload.event || {};
return `${payload.accountId || ""}|${event.type || ""}|${JSON.stringify(event.data || {})}`;
}
function isDuplicateRealtimeEvent(payload) {
const now = Date.now();
for (const [key, seenAt] of state.realtimeSeenEvents) if (now - seenAt > 10000) state.realtimeSeenEvents.delete(key);
const event = payload.event || {};
const fingerprint = `${payload.accountId || ""}|${event.type || ""}|${JSON.stringify(event.data || {})}`;
const idKey = payload.eventId ? `id:${payload.eventId}` : null;
const fingerprintKey = `event:${fingerprint}`;
const duplicate = Boolean((idKey && state.realtimeSeenEvents.has(idKey)) || (state.realtimeSeenEvents.get(fingerprintKey) && now - state.realtimeSeenEvents.get(fingerprintKey) < 1500));
if (idKey) state.realtimeSeenEvents.set(idKey, now);
state.realtimeSeenEvents.set(fingerprintKey, now);
return duplicate;
}
async function resyncRealtimeState() {
if (!state.accountId) return;
if (state.realtimeSyncPromise) return state.realtimeSyncPromise;
state.realtimeSyncPromise = (async () => {
await refreshFolderListOnly();
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true);
})().catch(() => { }).finally(() => { state.realtimeSyncPromise = null; });
return state.realtimeSyncPromise;
}
function scheduleRealtimeRefresh(event, accountId) {
if (accountId !== state.accountId) return;
state.realtimeRefreshEvents.push(event);
clearTimeout(state.realtimeRefreshTimer);
state.realtimeRefreshTimer = setTimeout(async () => {
const events = state.realtimeRefreshEvents.splice(0);
await refreshFolderListOnly();
if (["NewMessages", "FlagsChanged"].includes(event.type) && event.data?.folder === state.folder) {
const refreshCurrent = events.some((item) => ["NewMessages", "FlagsChanged"].includes(item.type) && item.data?.folder === state.folder);
if (refreshCurrent) {
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true);
}
}, 250);
}, 180);
}
function startRealtimeFallbackPolling() {
if (state.realtimePollTimer) return;
state.realtimePollTimer = setInterval(() => {
if (!state.accounts.length || document.visibilityState === "hidden") return;
const open = state.ws?.readyState === WebSocket.OPEN;
const stale = !state.wsLastHeartbeatAt || Date.now() - state.wsLastHeartbeatAt > 55000;
if (!open || stale) void resyncRealtimeState();
}, 30000);
}
function startRealtimeWatchdog(socket) {
clearInterval(state.wsWatchdogTimer);
state.wsWatchdogTimer = setInterval(() => {
if (state.ws !== socket || socket.readyState !== WebSocket.OPEN) return;
if (Date.now() - state.wsLastHeartbeatAt > 60000) socket.close(4000, "Heartbeat timeout");
}, 10000);
}
function scheduleRealtimeReconnect() {
if (!state.wsShouldConnect || !state.accounts.length || state.wsRetryTimer) return;
const attempt = Math.min(state.wsRetryAttempt, 6);
const base = Math.min(30000, 1000 * (2 ** attempt));
const delay = Math.round(base * (0.8 + Math.random() * 0.4));
state.wsRetryAttempt += 1;
state.wsRetryTimer = setTimeout(() => {
state.wsRetryTimer = null;
connectRealtime();
}, delay);
}
function connectRealtime() {
state.wsShouldConnect = true;
startRealtimeFallbackPolling();
if (!state.accounts.length) return;
if (state.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(state.ws.readyState)) return;
clearTimeout(state.wsRetryTimer);
state.wsRetryTimer = null;
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${location.host}${BASE}/api/ws`);
state.ws = socket;
socket.onopen = () => {
if (state.ws !== socket) return;
const reconnect = state.wsConnectedOnce;
state.wsConnectedOnce = true;
state.wsRetryAttempt = 0;
state.wsLastHeartbeatAt = Date.now();
startRealtimeWatchdog(socket);
if (reconnect) void resyncRealtimeState();
};
socket.onmessage = (message) => {
if (state.ws !== socket) return;
state.wsLastHeartbeatAt = Date.now();
try {
const payload = JSON.parse(message.data);
if (payload.type === "heartbeat") {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "heartbeat_ack", timestamp: payload.timestamp || Date.now() }));
return;
}
if (isDuplicateRealtimeEvent(payload)) return;
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);
if (state.ws !== socket) return;
state.ws = null;
clearInterval(state.wsWatchdogTimer);
state.wsWatchdogTimer = null;
scheduleRealtimeReconnect();
};
socket.onerror = () => socket.close();
socket.onerror = () => { if (socket.readyState < WebSocket.CLOSING) socket.close(); };
}
function disconnectRealtime() {
state.wsShouldConnect = false;
clearTimeout(state.wsRetryTimer);
clearTimeout(state.realtimeRefreshTimer);
clearInterval(state.wsWatchdogTimer);
clearInterval(state.realtimePollTimer);
state.wsRetryTimer = null;
state.realtimeRefreshTimer = null;
state.wsWatchdogTimer = null;
state.realtimePollTimer = null;
state.realtimeRefreshEvents = [];
state.wsRetryAttempt = 0;
state.wsLastHeartbeatAt = 0;
const socket = state.ws;
state.ws = null;
if (socket && socket.readyState < WebSocket.CLOSING) socket.close();
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Client disconnect");
}
function capabilityBadge(label, supported) {