From 4c77d129e7ed742fab624c4ec6fc4832171cafb8 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 25 May 2026 22:03:30 +0300 Subject: [PATCH] fix: update support page content without page refresh --- frontend/src/lib/admin/stores/supportStore.js | 200 ++++++++++++++++-- .../src/lib/webapp/stores/supportStore.js | 105 ++++++--- 2 files changed, 249 insertions(+), 56 deletions(-) diff --git a/frontend/src/lib/admin/stores/supportStore.js b/frontend/src/lib/admin/stores/supportStore.js index f1925ea..6a580d2 100644 --- a/frontend/src/lib/admin/stores/supportStore.js +++ b/frontend/src/lib/admin/stores/supportStore.js @@ -1,6 +1,11 @@ import { writable } from "svelte/store"; export function createAdminSupportStore({ api, onToast, at }) { + const OPEN_TICKET_POLL_MS = 3_000; + const STATS_POLL_MS = 30_000; + const HIDDEN_POLL_MS = 300_000; + const ERROR_POLL_MS = 90_000; + const state = writable({ tickets: [], stats: { active: 0, closed: 0, open: 0, awaiting_admin: 0, total_unread_admin: 0 }, @@ -21,13 +26,35 @@ export function createAdminSupportStore({ api, onToast, at }) { composerInternalNote: false, }); - let pollTimer = null; + let statsPollTimer = null; + let ticketPollTimer = null; + let ticketPollInFlight = false; + let visibilityHandler = null; + let resumeHandler = null; let active = "stats"; function setActive(section) { active = section; } + function getSnapshot() { + let snapshot; + const unsubscribe = state.subscribe((s) => { + snapshot = s; + }); + unsubscribe(); + return snapshot; + } + + function currentOpenedTicketId() { + return getSnapshot()?.openedTicketId || null; + } + + function lastMessageId(messages) { + const list = Array.isArray(messages) ? messages : []; + return Number(list.at(-1)?.message_id || 0); + } + function pushTicketPath(ticketId) { if (typeof window === "undefined" || window.location.protocol === "file:") return; if (active !== "support") return; @@ -46,13 +73,11 @@ export function createAdminSupportStore({ api, onToast, at }) { if (res?.ok) state.update((s) => ({ ...s, stats: res.stats || s.stats })); } - async function loadList() { - state.update((s) => ({ ...s, loading: true })); + async function loadList(options = {}) { + const silent = options.silent === true; + if (!silent) state.update((s) => ({ ...s, loading: true })); let filters; - state.update((s) => { - filters = s.filters; - return s; - }); + filters = getSnapshot()?.filters; try { const params = new URLSearchParams({ limit: "50", offset: "0" }); for (const [key, value] of Object.entries(filters || {})) { @@ -62,10 +87,45 @@ export function createAdminSupportStore({ api, onToast, at }) { if (res?.ok) state.update((s) => ({ ...s, tickets: res.tickets || [] })); else if (res?.error) onToast(res.message || res.error); } finally { - state.update((s) => ({ ...s, loading: false })); + if (!silent) state.update((s) => ({ ...s, loading: false })); } } + async function refreshCurrentTicket(ticketId) { + const id = Number(ticketId); + if (!id) return null; + const res = await api(`/admin/support/tickets/${id}`); + if (!res?.ok) return res; + + let shouldRefreshList = false; + let shouldMarkRead = false; + state.update((s) => { + if (s.openedTicketId !== id) return s; + const nextMessages = res.messages || []; + shouldRefreshList = + lastMessageId(nextMessages) !== lastMessageId(s.messages) || + res.ticket?.status !== s.openedTicket?.status || + Number(res.ticket?.unread_admin_count || 0) !== + Number(s.openedTicket?.unread_admin_count || 0); + shouldMarkRead = Number(res.ticket?.unread_admin_count || 0) > 0; + return { + ...s, + openedTicket: res.ticket, + messages: nextMessages, + userSnapshot: res.user_snapshot || null, + }; + }); + + if (currentOpenedTicketId() !== id) return res; + if (shouldMarkRead) { + await api(`/admin/support/tickets/${id}/read`, { method: "POST", body: "{}" }); + await loadStats(); + shouldRefreshList = true; + } + if (shouldRefreshList) await loadList({ silent: true }); + return res; + } + async function openTicket(ticketId, opts = {}) { const id = Number(ticketId); if (!id) return; @@ -81,17 +141,25 @@ export function createAdminSupportStore({ api, onToast, at }) { try { const res = await api(`/admin/support/tickets/${id}`); if (res?.ok) { - state.update((s) => ({ - ...s, - openedTicket: res.ticket, - messages: res.messages || [], - userSnapshot: res.user_snapshot || null, - })); - await api(`/admin/support/tickets/${id}/read`, { method: "POST", body: "{}" }); - await loadStats(); + state.update((s) => + s.openedTicketId === id + ? { + ...s, + openedTicket: res.ticket, + messages: res.messages || [], + userSnapshot: res.user_snapshot || null, + } + : s + ); + if (currentOpenedTicketId() === id) { + await api(`/admin/support/tickets/${id}/read`, { method: "POST", body: "{}" }); + await loadStats(); + await loadList({ silent: true }); + scheduleTicketPoll(OPEN_TICKET_POLL_MS); + } } else onToast(res?.message || res?.error || "not_found"); } finally { - state.update((s) => ({ ...s, detailLoading: false })); + state.update((s) => (s.openedTicketId === id ? { ...s, detailLoading: false } : s)); } } @@ -103,6 +171,7 @@ export function createAdminSupportStore({ api, onToast, at }) { messages: [], userSnapshot: null, })); + clearTicketPollTimer(); if (!opts.skipPush) pushTicketPath(null); } @@ -114,7 +183,10 @@ export function createAdminSupportStore({ api, onToast, at }) { internal = s.composerInternalNote; return { ...s, sending: true }; }); - if (!current) return; + if (!current) { + state.update((s) => ({ ...s, sending: false })); + return; + } try { const res = await api(`/admin/support/tickets/${current}/messages`, { method: "POST", @@ -183,17 +255,99 @@ export function createAdminSupportStore({ api, onToast, at }) { loadList(); } + function clearTicketPollTimer() { + if (!ticketPollTimer || typeof window === "undefined") return; + window.clearTimeout(ticketPollTimer); + ticketPollTimer = null; + } + + function scheduleTicketPoll(delayMs = OPEN_TICKET_POLL_MS) { + if (typeof window === "undefined") return; + clearTicketPollTimer(); + if (!currentOpenedTicketId()) return; + ticketPollTimer = window.setTimeout(runTicketPoll, Math.max(0, Number(delayMs) || 0)); + } + + async function runTicketPoll() { + ticketPollTimer = null; + if (typeof document !== "undefined" && document.visibilityState !== "visible") { + scheduleTicketPoll(HIDDEN_POLL_MS); + return; + } + const ticketId = currentOpenedTicketId(); + if (!ticketId) return; + if (ticketPollInFlight) { + scheduleTicketPoll(OPEN_TICKET_POLL_MS); + return; + } + + ticketPollInFlight = true; + let failed = false; + try { + const res = await refreshCurrentTicket(ticketId); + if (res?.error) failed = true; + } catch (_error) { + failed = true; + } finally { + ticketPollInFlight = false; + if (currentOpenedTicketId()) { + scheduleTicketPoll(failed ? ERROR_POLL_MS : OPEN_TICKET_POLL_MS); + } + } + } + + function ensureRealtimeListeners() { + if (typeof window === "undefined") return; + if (!visibilityHandler && typeof document !== "undefined") { + visibilityHandler = () => { + if (document.visibilityState === "visible") { + loadStats(); + scheduleTicketPoll(0); + } else { + scheduleTicketPoll(HIDDEN_POLL_MS); + } + }; + document.addEventListener("visibilitychange", visibilityHandler); + } + if (!resumeHandler) { + resumeHandler = () => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") return; + loadStats(); + scheduleTicketPoll(0); + }; + window.addEventListener("focus", resumeHandler); + window.addEventListener("pageshow", resumeHandler); + } + } + + function stopRealtimeListeners() { + if (visibilityHandler && typeof document !== "undefined") { + document.removeEventListener("visibilitychange", visibilityHandler); + visibilityHandler = null; + } + if (resumeHandler && typeof window !== "undefined") { + window.removeEventListener("focus", resumeHandler); + window.removeEventListener("pageshow", resumeHandler); + resumeHandler = null; + } + } + function startStatsPolling() { - if (pollTimer || typeof window === "undefined") return; + if (typeof window === "undefined") return; + ensureRealtimeListeners(); + if (statsPollTimer) return; loadStats(); - pollTimer = window.setInterval(() => { + statsPollTimer = window.setInterval(() => { if (document.visibilityState === "visible") loadStats(); - }, 30000); + }, STATS_POLL_MS); } function stopStatsPolling() { - if (pollTimer) window.clearInterval(pollTimer); - pollTimer = null; + if (statsPollTimer) window.clearInterval(statsPollTimer); + statsPollTimer = null; + clearTicketPollTimer(); + ticketPollInFlight = false; + stopRealtimeListeners(); } return { diff --git a/frontend/src/lib/webapp/stores/supportStore.js b/frontend/src/lib/webapp/stores/supportStore.js index 958c9fd..e39cac4 100644 --- a/frontend/src/lib/webapp/stores/supportStore.js +++ b/frontend/src/lib/webapp/stores/supportStore.js @@ -1,7 +1,8 @@ import { writable } from "svelte/store"; export function createSupportStore({ api, t, showToast }) { - const ACTIVE_POLL_MS = 15_000; + const OPEN_TICKET_POLL_MS = 3_000; + const ACTIVE_POLL_MS = 8_000; const BACKGROUND_POLL_MS = 45_000; const IDLE_POLL_MS = 120_000; const PAUSED_POLL_MS = 300_000; @@ -34,6 +35,7 @@ export function createSupportStore({ api, t, showToast }) { let emptyUnreadPolls = 0; let lastUnreadCount = 0; let visibilityHandler = null; + let resumeHandler = null; let listRequestSeq = 0; let listPromise = null; let listPromiseKey = ""; @@ -55,6 +57,19 @@ export function createSupportStore({ api, t, showToast }) { return BACKGROUND_POLL_MS; } + function getSnapshot() { + let snapshot; + const unsubscribe = state.subscribe((s) => { + snapshot = s; + }); + unsubscribe(); + return snapshot; + } + + function activePollDelay() { + return currentOpenedTicketId() ? OPEN_TICKET_POLL_MS : ACTIVE_POLL_MS; + } + function clearPollTimer() { if (!pollTimer) return; if (typeof window !== "undefined") window.clearTimeout(pollTimer); @@ -68,12 +83,7 @@ export function createSupportStore({ api, t, showToast }) { } function currentOpenedTicketId() { - let opened = null; - state.update((s) => { - opened = s.openedTicketId; - return s; - }); - return opened; + return getSnapshot()?.openedTicketId || null; } function hydrateUnread(value) { @@ -138,12 +148,18 @@ export function createSupportStore({ api, t, showToast }) { try { const res = await api(`/support/tickets/${id}`); if (res?.ok) { - state.update((s) => ({ - ...s, - openedTicket: res.ticket, - messages: res.messages || [], - })); - await markRead(id); + state.update((s) => + s.openedTicketId === id + ? { + ...s, + openedTicket: res.ticket, + messages: res.messages || [], + } + : s + ); + if (currentOpenedTicketId() === id && Number(res.ticket?.unread_user_count || 0) > 0) { + await markRead(id, { silent: true }); + } } return res; } catch { @@ -194,17 +210,22 @@ export function createSupportStore({ api, t, showToast }) { try { const res = await api(`/support/tickets/${id}`); if (res?.ok) { - state.update((s) => ({ - ...s, - openedTicket: res.ticket, - messages: res.messages || [], - })); - await markRead(id); + state.update((s) => + s.openedTicketId === id + ? { + ...s, + openedTicket: res.ticket, + messages: res.messages || [], + } + : s + ); + if (currentOpenedTicketId() === id) await markRead(id); } else { showToast(res?.message || res?.error || "not_found"); } } finally { - state.update((s) => ({ ...s, detailLoading: false })); + state.update((s) => (s.openedTicketId === id ? { ...s, detailLoading: false } : s)); + if (pollingEnabled) schedulePoll(activePollDelay()); } } @@ -227,7 +248,10 @@ export function createSupportStore({ api, t, showToast }) { ticketId = s.openedTicketId; return { ...s, sending: true }; }); - if (!ticketId) return; + if (!ticketId) { + state.update((s) => ({ ...s, sending: false })); + return; + } try { const res = await api(`/support/tickets/${ticketId}/messages`, { method: "POST", @@ -248,20 +272,15 @@ export function createSupportStore({ api, t, showToast }) { } } - async function markRead(ticketId = null) { + async function markRead(ticketId = null, options = {}) { const id = ticketId || (() => { - let current = null; - state.update((s) => { - current = s.openedTicketId; - return s; - }); - return current; + return currentOpenedTicketId(); })(); if (!id) return; await api(`/support/tickets/${id}/read`, { method: "POST", body: "{}" }); - await refreshUnread(); + await refreshUnread({ silent: options.silent === true }); } async function refreshUnread(options = {}) { @@ -318,7 +337,9 @@ export function createSupportStore({ api, t, showToast }) { failed = true; } finally { pollInFlight = false; - if (pollingEnabled) schedulePoll(failed ? ERROR_POLL_MS : nextPollDelay()); + if (pollingEnabled) { + schedulePoll(failed ? ERROR_POLL_MS : supportActive ? activePollDelay() : nextPollDelay()); + } } } @@ -327,7 +348,7 @@ export function createSupportStore({ api, t, showToast }) { if (supportActive === next) return; supportActive = next; if (supportActive) emptyUnreadPolls = 0; - if (pollingEnabled) schedulePoll(supportActive ? ACTIVE_POLL_MS : nextPollDelay()); + if (pollingEnabled) schedulePoll(supportActive ? 0 : nextPollDelay()); } function startPolling(options = {}) { @@ -348,10 +369,20 @@ export function createSupportStore({ api, t, showToast }) { }; document.addEventListener("visibilitychange", visibilityHandler); } + if (!resumeHandler) { + resumeHandler = () => { + if (!pollingEnabled) return; + if (typeof document !== "undefined" && document.visibilityState === "hidden") return; + emptyUnreadPolls = 0; + schedulePoll(0); + }; + window.addEventListener("focus", resumeHandler); + window.addEventListener("pageshow", resumeHandler); + } if (!pollTimer && !pollInFlight) { - schedulePoll(supportActive ? ACTIVE_POLL_MS : nextPollDelay()); + schedulePoll(supportActive ? 0 : nextPollDelay()); } else if (supportActive) { - schedulePoll(ACTIVE_POLL_MS); + schedulePoll(activePollDelay()); } } @@ -361,6 +392,13 @@ export function createSupportStore({ api, t, showToast }) { visibilityHandler = null; } + function stopResumeListeners() { + if (!resumeHandler || typeof window === "undefined") return; + window.removeEventListener("focus", resumeHandler); + window.removeEventListener("pageshow", resumeHandler); + resumeHandler = null; + } + function closePolling() { pollingEnabled = false; supportActive = false; @@ -368,6 +406,7 @@ export function createSupportStore({ api, t, showToast }) { emptyUnreadPolls = 0; clearPollTimer(); stopVisibilityListener(); + stopResumeListeners(); state.update((s) => (s.polling ? { ...s, polling: false } : s)); }