1 parent
96a32ea8fb
commit
a643e7e3ab
106 files changed
+2223
-4669
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user