1 parent
cfc74d9db6
commit
dacdf9c27d
76 files changed
+5209
-741
No files matched your search
@@ -160,17 +160,48 @@ function playNotificationSound() {
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function setIndexingIndicator(active, pending = 0) {
|
||||
state.indexingActive = Boolean(active);
|
||||
function renderSyncActivityIndicator() {
|
||||
const indicator = $("#indexing-indicator");
|
||||
if (!indicator) return;
|
||||
indicator.classList.toggle("hidden", !state.indexingActive);
|
||||
const count = Math.max(0, Number(pending) || 0);
|
||||
const label = count > 0 ? `Indexing messages (${count} pending)` : "Indexing messages";
|
||||
const busy = Boolean(state.indexingActive || state.imapSyncActive);
|
||||
indicator.classList.toggle("hidden", !busy);
|
||||
indicator.classList.toggle("active", busy);
|
||||
const parts = [];
|
||||
if (state.imapSyncActive || state.imapSyncPending > 0) {
|
||||
const folder = state.imapSyncFolder ? ` · ${state.imapSyncFolder}` : "";
|
||||
const pending = state.imapSyncPending > 0 ? ` · ${state.imapSyncPending} headers pending in task` : "";
|
||||
parts.push(`IMAP synchronization${folder}${pending}`);
|
||||
}
|
||||
if (state.imapSyncInventoryPending > 0) {
|
||||
parts.push(`IMAP cache incomplete · ${state.imapSyncInventoryPending} message headers not cached yet`);
|
||||
}
|
||||
if (state.indexingActive || state.indexingPending > 0) {
|
||||
const pending = state.indexingPending > 0 ? ` · ${state.indexingPending} messages pending` : "";
|
||||
parts.push(`Search indexing${pending}`);
|
||||
}
|
||||
const label = parts.join(" | ") || "Mail synchronization";
|
||||
indicator.title = label;
|
||||
indicator.setAttribute("aria-label", label);
|
||||
}
|
||||
|
||||
function setIndexingIndicator(active, pending = 0) {
|
||||
state.indexingActive = Boolean(active);
|
||||
state.indexingPending = Math.max(0, Number(pending) || 0);
|
||||
renderSyncActivityIndicator();
|
||||
}
|
||||
|
||||
function setImapSyncIndicator(active, folder = null, pending = 0) {
|
||||
state.imapSyncActive = Boolean(active);
|
||||
state.imapSyncFolder = folder || null;
|
||||
state.imapSyncPending = Math.max(0, Number(pending) || 0);
|
||||
renderSyncActivityIndicator();
|
||||
}
|
||||
|
||||
function setImapSyncInventoryPending(pending = 0) {
|
||||
state.imapSyncInventoryPending = Math.max(0, Number(pending) || 0);
|
||||
renderSyncActivityIndicator();
|
||||
}
|
||||
|
||||
function syncRealtimeWatchedFolder(force = false) {
|
||||
const socket = state.ws;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !state.accountId || !state.folder) return;
|
||||
@@ -296,23 +327,62 @@ function realtimeEventKey(payload) {
|
||||
function isDuplicateRealtimeEvent(payload) {
|
||||
const now = Date.now();
|
||||
for (const [key, seenAt] of state.realtimeSeenEvents) if (now - seenAt > 10000) state.realtimeSeenEvents.delete(key);
|
||||
|
||||
// Server websocket envelopes carry a unique eventId. When present it is the
|
||||
// only safe deduplication key: state can legitimately cycle A -> B -> A in
|
||||
// under a second (for example unread 1 -> 0 -> 1). Fingerprint-based
|
||||
// deduplication would incorrectly drop the second A and leave stale UI.
|
||||
if (payload.eventId) {
|
||||
const idKey = `id:${payload.eventId}`;
|
||||
const duplicate = state.realtimeSeenEvents.has(idKey);
|
||||
state.realtimeSeenEvents.set(idKey, now);
|
||||
return duplicate;
|
||||
}
|
||||
|
||||
// Fallback for legacy/nonstandard envelopes without eventId.
|
||||
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);
|
||||
const seenAt = state.realtimeSeenEvents.get(fingerprintKey);
|
||||
const duplicate = Boolean(seenAt && now - seenAt < 1500);
|
||||
state.realtimeSeenEvents.set(fingerprintKey, now);
|
||||
return duplicate;
|
||||
}
|
||||
|
||||
function applyRealtimeFolderStatus(data) {
|
||||
if (!data?.folder) return;
|
||||
setFolderCounts(data.folder, Number(data.total_count), Number(data.unread_count));
|
||||
}
|
||||
|
||||
function applyRealtimeFlagChanges(data) {
|
||||
if (!data?.folder) return false;
|
||||
const changes = Array.isArray(data.changes) ? data.changes : [];
|
||||
for (const change of changes) {
|
||||
if (!Number.isFinite(Number(change?.uid))) continue;
|
||||
applyLocalMessageFlags(data.folder, Number(change.uid), change.flags || []);
|
||||
}
|
||||
return changes.length > 0;
|
||||
}
|
||||
|
||||
function applyRealtimeFolderAllRead(data) {
|
||||
const folder = data?.folder;
|
||||
if (!folder || folder !== state.folder) return;
|
||||
const throughUid = Number(data?.through_uid);
|
||||
for (const message of state.messages) {
|
||||
if (Number.isFinite(throughUid) && Number(message.uid) > throughUid) continue;
|
||||
const flags = normalizedMessageFlags(message.flags);
|
||||
if (!flags.includes("\\Seen")) flags.push("\\Seen");
|
||||
applyLocalMessageFlags(folder, message.uid, flags);
|
||||
}
|
||||
}
|
||||
|
||||
async function resyncRealtimeState() {
|
||||
if (!state.accountId) return;
|
||||
if (state.realtimeSyncPromise) return state.realtimeSyncPromise;
|
||||
state.realtimeSyncPromise = (async () => {
|
||||
await refreshFolderListOnly();
|
||||
await refreshFolderListOnly({ forceServer: true });
|
||||
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
|
||||
else await loadMessages(true);
|
||||
else await loadMessages(true, { background: true });
|
||||
})().catch(() => { }).finally(() => { state.realtimeSyncPromise = null; });
|
||||
return state.realtimeSyncPromise;
|
||||
}
|
||||
@@ -323,13 +393,16 @@ function scheduleRealtimeRefresh(event, accountId) {
|
||||
clearTimeout(state.realtimeRefreshTimer);
|
||||
state.realtimeRefreshTimer = setTimeout(async () => {
|
||||
const events = state.realtimeRefreshEvents.splice(0);
|
||||
const refreshCurrent = events.some((item) => item.type === "FolderUpdated" || (["NewMessages", "FlagsChanged"].includes(item.type) && item.data?.folder === state.folder));
|
||||
const tasks = [refreshFolderListOnly()];
|
||||
if (refreshCurrent) {
|
||||
const folderTreeChanged = events.some((item) => item.type === "FolderUpdated");
|
||||
const newInCurrent = events.some((item) => item.type === "NewMessages" && item.data?.folder === state.folder);
|
||||
const structuralFlagsInCurrent = events.some((item) => item.type === "FlagsChanged" && item.data?.folder === state.folder && !(item.data?.changes || []).length);
|
||||
const tasks = [];
|
||||
if (folderTreeChanged) tasks.push(refreshFolderListOnly());
|
||||
if (folderTreeChanged || newInCurrent || structuralFlagsInCurrent) {
|
||||
if (state.searchContext) tasks.push(runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop }));
|
||||
else tasks.push(loadMessages(true));
|
||||
else tasks.push(loadMessages(true, { background: true }));
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
if (tasks.length) await Promise.all(tasks);
|
||||
}, 80);
|
||||
}
|
||||
|
||||
@@ -402,10 +475,29 @@ function connectRealtime() {
|
||||
notifyNewMail(data, payload.accountId);
|
||||
}
|
||||
}
|
||||
if (event.type === "IndexingStatus" && payload.accountId === state.accountId) {
|
||||
setIndexingIndicator(Boolean(event.data?.active), event.data?.pending);
|
||||
if (payload.accountId === state.accountId) {
|
||||
if (event.type === "FolderStatus") applyRealtimeFolderStatus(event.data || {});
|
||||
if (event.type === "FlagsChanged") {
|
||||
const patched = applyRealtimeFlagChanges(event.data || {});
|
||||
if (!patched) scheduleRealtimeRefresh(event, payload.accountId);
|
||||
}
|
||||
if (event.type === "FolderAllRead") applyRealtimeFolderAllRead(event.data || {});
|
||||
if (event.type === "IndexingStatus") setIndexingIndicator(Boolean(event.data?.active), event.data?.pending);
|
||||
if (event.type === "SyncStatus") {
|
||||
const wasActive = state.imapSyncActive;
|
||||
const syncFolder = event.data?.folder || null;
|
||||
const active = Boolean(event.data?.active);
|
||||
setImapSyncIndicator(active, syncFolder, event.data?.pending);
|
||||
const folderState = state.folders.find((folder) => folder.name === syncFolder);
|
||||
const serverTotal = Number(folderState?.total_count);
|
||||
const syncCompletedForCurrentFolder = wasActive && !active && syncFolder === state.folder;
|
||||
if (syncCompletedForCurrentFolder && !state.searchContext && Number.isFinite(serverTotal) && state.total < serverTotal) {
|
||||
void loadMessages(true, { background: true });
|
||||
}
|
||||
}
|
||||
if (event.type === "SyncInventory") setImapSyncInventoryPending(event.data?.pending);
|
||||
}
|
||||
if (["NewMessages", "FlagsChanged", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId);
|
||||
if (["NewMessages", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId);
|
||||
} catch (error) { console.warn("Invalid realtime event", error); }
|
||||
};
|
||||
socket.onclose = () => {
|
||||
@@ -413,6 +505,7 @@ function connectRealtime() {
|
||||
state.ws = null;
|
||||
state.realtimeWatchedFolder = null;
|
||||
setIndexingIndicator(false);
|
||||
setImapSyncIndicator(false, null, 0);
|
||||
clearInterval(state.wsWatchdogTimer);
|
||||
state.wsWatchdogTimer = null;
|
||||
scheduleRealtimeReconnect();
|
||||
@@ -435,6 +528,7 @@ function disconnectRealtime() {
|
||||
state.wsLastHeartbeatAt = 0;
|
||||
state.realtimeWatchedFolder = null;
|
||||
setIndexingIndicator(false);
|
||||
setImapSyncIndicator(false, null, 0);
|
||||
const socket = state.ws;
|
||||
state.ws = null;
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Client disconnect");
|
||||
@@ -516,7 +610,8 @@ async function loadDiagnostics() {
|
||||
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` : ""}`;
|
||||
const bodyCacheState = info.deepIndexEnabled ? "body cache enabled" : "body cache disabled";
|
||||
$("#diagnostic-search-summary").textContent = `${info.indexedDocuments} indexed · ${info.cachedMessages} cached messages · ${info.cachedBodies} cached bodies · ${bodyCacheState}${info.excludedMessages ? ` · ${info.excludedMessages} excluded` : ""}`;
|
||||
} catch (error) {
|
||||
searchStatus.textContent = "Unavailable";
|
||||
$("#diagnostic-search-summary").textContent = error.message;
|
||||
@@ -542,6 +637,7 @@ 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;
|
||||
setIndexingIndicator(true, 0);
|
||||
try {
|
||||
const result = await request("/diagnostics/search/rebuild", { method: "POST", body: "{}" });
|
||||
toast(`Search index rebuilt: ${result.indexedDocuments} messages`);
|
||||
@@ -549,6 +645,7 @@ async function rebuildSearchIndex() {
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
} finally {
|
||||
setIndexingIndicator(false, 0);
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user