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 - +