From e7301e2c486659b609a4cca3ccae4f3c2d072d98 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 21 May 2026 13:41:01 +0300 Subject: [PATCH] refactor: optimize support polling --- frontend/src/App.svelte | 4 +- .../src/lib/webapp/stores/supportStore.js | 157 +++++++++++++++--- 2 files changed, 133 insertions(+), 28 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 59b5c95..fe4f70a 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -300,6 +300,7 @@ : plans; $: devicesEnabled = Boolean(appSettings?.my_devices_enabled); $: supportEnabled = Boolean(appSettings?.support_tickets_enabled ?? true); + $: supportStore.setActive(Boolean(mode === "app" && screen === "support" && supportEnabled)); $: subscription = data?.subscription || DEV_MOCK.data.subscription; $: hasActiveTariffSubscription = Boolean( tariffMode && subscription?.active && subscription?.tariff_key @@ -851,8 +852,9 @@ if (payload.settings?.support_tickets_enabled !== false) { if (typeof payload.support_unread_count !== "undefined") { supportStore.hydrateUnread(payload.support_unread_count); + } else { + void supportStore.refreshUnread(); } - void supportStore.refreshUnread(); supportStore.startPolling({ includeList: false }); } if (section === "support" && initialSupportTicketId) { diff --git a/frontend/src/lib/webapp/stores/supportStore.js b/frontend/src/lib/webapp/stores/supportStore.js index ee6cd19..958c9fd 100644 --- a/frontend/src/lib/webapp/stores/supportStore.js +++ b/frontend/src/lib/webapp/stores/supportStore.js @@ -1,6 +1,15 @@ import { writable } from "svelte/store"; export function createSupportStore({ api, t, showToast }) { + const ACTIVE_POLL_MS = 15_000; + const BACKGROUND_POLL_MS = 45_000; + const IDLE_POLL_MS = 120_000; + const PAUSED_POLL_MS = 300_000; + const HIDDEN_POLL_MS = 300_000; + const ERROR_POLL_MS = 90_000; + const IDLE_AFTER_EMPTY_POLLS = 3; + const PAUSE_AFTER_EMPTY_POLLS = 6; + const state = writable({ tickets: [], openedTicketId: null, @@ -19,14 +28,56 @@ export function createSupportStore({ api, t, showToast }) { }); let pollTimer = null; - let pollIncludeList = false; + let pollingEnabled = false; + let pollInFlight = false; + let supportActive = false; + let emptyUnreadPolls = 0; + let lastUnreadCount = 0; + let visibilityHandler = null; let listRequestSeq = 0; let listPromise = null; let listPromiseKey = ""; let unreadPromise = null; - function hydrateUnread(value) { + function updateUnreadBackoff(value, countEmptyPoll = false) { const next = Math.max(0, Number(value || 0)); + if (countEmptyPoll && next === 0 && next === lastUnreadCount) emptyUnreadPolls += 1; + else if (next > 0 || next !== lastUnreadCount) emptyUnreadPolls = 0; + lastUnreadCount = next; + return next; + } + + function nextPollDelay() { + if (supportActive) return ACTIVE_POLL_MS; + if (lastUnreadCount > 0) return BACKGROUND_POLL_MS; + if (emptyUnreadPolls >= PAUSE_AFTER_EMPTY_POLLS) return PAUSED_POLL_MS; + if (emptyUnreadPolls >= IDLE_AFTER_EMPTY_POLLS) return IDLE_POLL_MS; + return BACKGROUND_POLL_MS; + } + + function clearPollTimer() { + if (!pollTimer) return; + if (typeof window !== "undefined") window.clearTimeout(pollTimer); + pollTimer = null; + } + + function schedulePoll(delayMs = nextPollDelay()) { + if (!pollingEnabled || typeof window === "undefined") return; + clearPollTimer(); + pollTimer = window.setTimeout(runPollTick, Math.max(0, Number(delayMs) || 0)); + } + + function currentOpenedTicketId() { + let opened = null; + state.update((s) => { + opened = s.openedTicketId; + return s; + }); + return opened; + } + + function hydrateUnread(value) { + const next = updateUnreadBackoff(value); state.update((s) => ({ ...s, unreadCount: next, @@ -213,22 +264,24 @@ export function createSupportStore({ api, t, showToast }) { await refreshUnread(); } - async function refreshUnread() { + async function refreshUnread(options = {}) { if (unreadPromise) return unreadPromise; - state.update((s) => ({ ...s, unreadLoading: true })); + const silent = options.silent === true; + if (!silent) state.update((s) => ({ ...s, unreadLoading: true })); unreadPromise = (async () => { try { const res = await api("/support/unread"); if (res?.ok) { + const unreadCount = updateUnreadBackoff(res.unread, options.countEmpty === true); state.update((s) => ({ ...s, - unreadCount: Math.max(0, Number(res.unread || 0)), + unreadCount, unreadLoaded: true, })); } return res; } finally { - state.update((s) => ({ ...s, unreadLoading: false })); + if (!silent) state.update((s) => ({ ...s, unreadLoading: false })); unreadPromise = null; } })(); @@ -240,33 +293,82 @@ export function createSupportStore({ api, t, showToast }) { loadList({ force: true, showLoading: true }); } - function startPolling(options = {}) { - const includeList = options.includeList !== false; - pollIncludeList = pollIncludeList || includeList; - if (pollTimer || typeof window === "undefined") return; - state.update((s) => ({ ...s, polling: true })); - const tick = async () => { - if (document.visibilityState === "visible") { - await refreshUnread(); - if (!pollIncludeList) return; - let opened = null; - state.update((s) => { - opened = s.openedTicketId; - return s; - }); + async function runPollTick() { + pollTimer = null; + if (!pollingEnabled || typeof document === "undefined") return; + if (document.visibilityState !== "visible") { + schedulePoll(HIDDEN_POLL_MS); + return; + } + if (pollInFlight) { + schedulePoll(ACTIVE_POLL_MS); + return; + } + + pollInFlight = true; + let failed = false; + try { + await refreshUnread({ silent: true, countEmpty: true }); + if (supportActive) { + const opened = currentOpenedTicketId(); if (opened) await refreshCurrentTicket(opened); else await loadList({ silent: true }); } - }; - pollTimer = window.setInterval(tick, 15000); - document.addEventListener("visibilitychange", tick); + } catch (_error) { + failed = true; + } finally { + pollInFlight = false; + if (pollingEnabled) schedulePoll(failed ? ERROR_POLL_MS : nextPollDelay()); + } + } + + function setActive(active) { + const next = Boolean(active); + if (supportActive === next) return; + supportActive = next; + if (supportActive) emptyUnreadPolls = 0; + if (pollingEnabled) schedulePoll(supportActive ? ACTIVE_POLL_MS : nextPollDelay()); + } + + function startPolling(options = {}) { + const includeList = options.includeList !== false; + if (typeof window === "undefined") return; + pollingEnabled = true; + if (includeList) supportActive = true; + state.update((s) => (s.polling ? s : { ...s, polling: true })); + if (!visibilityHandler && typeof document !== "undefined") { + visibilityHandler = () => { + if (!pollingEnabled) return; + if (document.visibilityState === "visible") { + emptyUnreadPolls = 0; + schedulePoll(0); + } else { + schedulePoll(HIDDEN_POLL_MS); + } + }; + document.addEventListener("visibilitychange", visibilityHandler); + } + if (!pollTimer && !pollInFlight) { + schedulePoll(supportActive ? ACTIVE_POLL_MS : nextPollDelay()); + } else if (supportActive) { + schedulePoll(ACTIVE_POLL_MS); + } + } + + function stopVisibilityListener() { + if (!visibilityHandler || typeof document === "undefined") return; + document.removeEventListener("visibilitychange", visibilityHandler); + visibilityHandler = null; } function closePolling() { - if (pollTimer) window.clearInterval(pollTimer); - pollTimer = null; - pollIncludeList = false; - state.update((s) => ({ ...s, polling: false })); + pollingEnabled = false; + supportActive = false; + pollInFlight = false; + emptyUnreadPolls = 0; + clearPollTimer(); + stopVisibilityListener(); + state.update((s) => (s.polling ? { ...s, polling: false } : s)); } return { @@ -281,6 +383,7 @@ export function createSupportStore({ api, t, showToast }) { markRead, refreshUnread, setStatusFilter, + setActive, startPolling, closePolling, };