From ef7a9a330f2de61ff68f57ba046f9b7b8a90501f Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Mon, 8 Jun 2026 17:25:49 -0400 Subject: [PATCH] - Fixed polling after voice/text sends. Changes include: Stalled polls abort and retry automatically. Polling now schedules after each completed request. Local sends no longer advance the server cursor and skip interleaved messages. Sends trigger immediate reconciliation. API requests bypass browser caches. --- README.md | 1 + admin.php | 1 + api/config.php | 1 + assets/js/cyberchat-v4.js | 64 +++++++++++++++++++++++++++++++-------- config.json | 1 + embed-example.html | 8 ++--- index.html | 4 +-- 7 files changed, 62 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index e5da93a..c7dbc12 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat applicatio - Premium-configurable automatic voice sending - Tier-configurable automatic playback of newly received voice clips - Administrator tier overrides that grant plan features without payment +- Resilient polling with stalled-request timeouts and post-send reconciliation - Plan-based custom username colors - Personal text archives retained for at least 30 days - Plan-based voice archive access and JSON/CSV exports diff --git a/admin.php b/admin.php index 459aa0e..99347a9 100644 --- a/admin.php +++ b/admin.php @@ -1719,6 +1719,7 @@ td.actions{white-space:nowrap;width:1px} chat.max_message_lengthintegerMax chars per message chat.max_username_lengthintegerMax callsign length chat.poll_interval_msintegerClient poll interval (ms) + chat.poll_timeout_msintegerAbort and retry a stalled poll after this many milliseconds chat.session_lock_ipboolLock session to IP voice.enabledboolShow voice recording controls voice.max_duration_secondsintegerMaximum voice clip duration diff --git a/api/config.php b/api/config.php index 6d5f2bc..55c8191 100644 --- a/api/config.php +++ b/api/config.php @@ -9,6 +9,7 @@ jsonResponse([ 'max_message_length' => $config['chat']['max_message_length'] ?? 500, 'max_username_length' => $config['chat']['max_username_length'] ?? 12, 'poll_interval_ms' => $config['chat']['poll_interval_ms'] ?? 2000, + 'poll_timeout_ms' => $config['chat']['poll_timeout_ms'] ?? 12000, ], 'ui' => $config['ui'] ?? [], 'security' => ['min_password_length' => $config['security']['min_password_length'] ?? 4], diff --git a/assets/js/cyberchat-v4.js b/assets/js/cyberchat-v4.js index c9fcc3d..68db9ed 100644 --- a/assets/js/cyberchat-v4.js +++ b/assets/js/cyberchat-v4.js @@ -6,7 +6,8 @@ const state = { config: {}, user: null, groups: [], billing: null, view: ['chat', 'groups', 'archive', 'account'].includes(initialView) ? initialView : 'chat', - groupId: null, lastId: 0, pollTimer: null, polling: false, pollGeneration: 0, groupsAccess: null, + groupId: null, lastId: 0, pollTimer: null, pollController: null, + polling: false, pollRequested: false, pollGeneration: 0, groupsAccess: null, groupSeen: {}, groupUnread: {}, loginChallenge: initialChallenge, verifyToken: new URLSearchParams(location.search).get('verify') || '', @@ -37,7 +38,11 @@ return { method: 'POST', body }; } async function api(file, options, query = '') { - const response = await fetch(apiUrl(file, query), { credentials: 'include', ...(options || {}) }); + const response = await fetch(apiUrl(file, query), { + credentials: 'include', + cache: 'no-store', + ...(options || {}), + }); const data = await response.json().catch(() => ({ error: 'The server returned an invalid response' })); if (!response.ok && !data.error) data.error = 'Request failed'; return data; @@ -383,35 +388,63 @@ const message = input.value.trim(); if (!message) return; input.value = ''; - const result = await api('messages.php', form({ action: 'send', message, group_id: state.groupId || '' })); + const result = await api('messages.php', form({ + action: 'send', message, group_id: state.groupId || '', + })).catch(() => ({ error: 'Message send failed. Polling will continue.' })); if (result.groups_enabled === false) { input.value = message; return disableGroups(result.error, result.groups_platform_enabled !== false); } if (result.error) { input.value = message; return toast(result.error); } appendMessage(result.message); - state.lastId = Math.max(state.lastId, result.message.id); + requestPoll(); } function startPoll() { stopPoll(); const generation = state.pollGeneration; - poll(generation); - state.pollTimer = setInterval(() => poll(generation), state.config.chat?.poll_interval_ms || 2000); + schedulePoll(generation, 0); + } + function schedulePoll(generation, delay = state.config.chat?.poll_interval_ms || 2000) { + if (generation !== state.pollGeneration || state.view !== 'chat') return; + if (state.pollTimer) clearTimeout(state.pollTimer); + state.pollTimer = setTimeout(async () => { + state.pollTimer = null; + await poll(generation); + if (generation !== state.pollGeneration || state.view !== 'chat') return; + const delay = state.pollRequested ? 0 : state.config.chat?.poll_interval_ms || 2000; + state.pollRequested = false; + schedulePoll(generation, delay); + }, Math.max(0, delay)); + } + function requestPoll() { + if (state.view !== 'chat') return; + if (state.polling) { + state.pollRequested = true; + return; + } + schedulePoll(state.pollGeneration, 0); } function stopPoll() { - if (state.pollTimer) clearInterval(state.pollTimer); + if (state.pollTimer) clearTimeout(state.pollTimer); + state.pollController?.abort(); state.pollTimer = null; + state.pollController = null; state.polling = false; + state.pollRequested = false; state.pollGeneration++; } async function poll(generation = state.pollGeneration) { if (state.polling || state.view !== 'chat' || generation !== state.pollGeneration) return; const requestedGroup = state.groupId; const requestedSince = state.lastId; + const controller = new AbortController(); + const timeoutMs = Math.max(1000, Number(state.config.chat?.poll_timeout_ms) || 12000); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + state.pollController = controller; state.polling = true; try { const query = new URLSearchParams({ action: 'poll', since: requestedSince, group_id: requestedGroup || '' }); - const result = await api('messages.php', null, query.toString()).catch(() => null); + const result = await api('messages.php', { signal: controller.signal }, query.toString()).catch(() => null); if (generation !== state.pollGeneration || Number(state.groupId || 0) !== Number(requestedGroup || 0)) return; if (result && !result.error && Number(result.group_id || 0) === Number(requestedGroup || 0)) { const availabilityChanged = applyGroupsAvailability( @@ -429,7 +462,9 @@ if (groupsAvailable()) syncGroups(result.groups, true); if (requestedSince === 0) renderMessages(result.messages || []); else (result.messages || []).forEach(message => appendMessage(message, true)); - if (result.messages?.length) state.lastId = result.messages[result.messages.length - 1].id; + if (result.messages?.length) { + state.lastId = Math.max(state.lastId, ...result.messages.map(message => Number(message.id) || 0)); + } if (requestedGroup !== null) { state.groupUnread[requestedGroup] = false; state.groupSeen[requestedGroup] = Math.max(Number(state.groupSeen[requestedGroup] || 0), Number(state.lastId || 0)); @@ -445,6 +480,8 @@ if (result?.error && requestedGroup !== null) await recoverGroupAccess(requestedGroup, generation); } } finally { + clearTimeout(timeout); + if (state.pollController === controller) state.pollController = null; if (generation === state.pollGeneration) state.polling = false; } } @@ -470,7 +507,9 @@ const list = $('#messages'); list.innerHTML = messages.length ? '' : '
NO MESSAGES IN THIS CHANNEL TODAY
'; messages.forEach(appendMessage); - if (messages.length) state.lastId = messages[messages.length - 1].id; + if (messages.length) { + state.lastId = Math.max(state.lastId, ...messages.map(message => Number(message.id) || 0)); + } list.scrollTop = list.scrollHeight; } function appendMessage(message, incoming = false) { @@ -608,13 +647,14 @@ body.append('group_id', state.groupId || ''); body.append('duration', state.voice.duration.toFixed(2)); body.append('voice', state.voice.blob, 'voice.webm'); - const result = await api('messages.php', { method: 'POST', body }); + const result = await api('messages.php', { method: 'POST', body }) + .catch(() => ({ error: 'Voice send failed. Polling will continue.' })); state.voice.sending = false; if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false); if (result.error) return toast(result.error); appendMessage(result.message); - state.lastId = Math.max(state.lastId, result.message.id); discardVoice(); + requestPoll(); } function groupsView() { diff --git a/config.json b/config.json index 3c774da..37edbd2 100644 --- a/config.json +++ b/config.json @@ -6,6 +6,7 @@ "max_message_length": 500, "max_username_length": 12, "poll_interval_ms": 2000, + "poll_timeout_ms": 12000, "messages_per_page": 100, "session_lock_ip": true, "session_lock_cookie": true, diff --git a/embed-example.html b/embed-example.html index c77d6b3..ca91026 100644 --- a/embed-example.html +++ b/embed-example.html @@ -6,7 +6,7 @@ CyberChat — Embed Example - +