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

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-01 20:40:40 +01:00
1 parent cfc74d9db6
commit dacdf9c27d
76 files changed
+5209 -741

No files matched your search

+116 -19
View File
@@ -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;
}
}