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.
This commit is contained in:
Ty Clifford
2026-06-08 17:25:49 -04:00
parent 7214bb69f9
commit ef7a9a330f
7 changed files with 62 additions and 18 deletions
+1
View File
@@ -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
+1
View File
@@ -1719,6 +1719,7 @@ td.actions{white-space:nowrap;width:1px}
<tr><td class="mono">chat.max_message_length</td><td class="dim">integer</td><td>Max chars per message</td></tr>
<tr><td class="mono">chat.max_username_length</td><td class="dim">integer</td><td>Max callsign length</td></tr>
<tr><td class="mono">chat.poll_interval_ms</td><td class="dim">integer</td><td>Client poll interval (ms)</td></tr>
<tr><td class="mono">chat.poll_timeout_ms</td><td class="dim">integer</td><td>Abort and retry a stalled poll after this many milliseconds</td></tr>
<tr><td class="mono">chat.session_lock_ip</td><td class="dim">bool</td><td>Lock session to IP</td></tr>
<tr><td class="mono">voice.enabled</td><td class="dim">bool</td><td>Show voice recording controls</td></tr>
<tr><td class="mono">voice.max_duration_seconds</td><td class="dim">integer</td><td>Maximum voice clip duration</td></tr>
+1
View File
@@ -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],
+52 -12
View File
@@ -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 ? '' : '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
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() {
+1
View File
@@ -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,
+4 -4
View File
@@ -6,7 +6,7 @@
<title>CyberChat — Embed Example</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.4">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.5">
<style>
html, body {
height: auto;
@@ -88,13 +88,13 @@
<span class="kw">&lt;link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&amp;family=Rajdhani:wght@400;600;700&amp;family=Orbitron:wght@700;900&amp;display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 2. CyberChat stylesheet --&gt;</span>
<span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260608.4"</span><span class="kw">&gt;</span>
<span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260608.5"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 3. Container with any dimensions --&gt;</span>
<span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">&gt;&lt;/div&gt;</span>
<span class="cm">&lt;!-- 4. Script + init --&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-v4.js?v=20260608.4"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-v4.js?v=20260608.5"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script&gt;</span>
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
CyberChat.init(<span class="str">'#my-chat'</span>);
@@ -104,7 +104,7 @@
<script>
window.CYBERCHAT_BASE = '';
</script>
<script src="assets/js/cyberchat-v4.js?v=20260608.4"></script>
<script src="assets/js/cyberchat-v4.js?v=20260608.5"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#chat-here');
+2 -2
View File
@@ -7,14 +7,14 @@
<title>Chat @ TyClifford.com</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.4">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.5">
</head>
<body>
<div id="app"></div>
<script>
window.CYBERCHAT_BASE = '';
</script>
<script src="assets/js/cyberchat-v4.js?v=20260608.4"></script>
<script src="assets/js/cyberchat-v4.js?v=20260608.5"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#app');