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_length integer Max chars per message
chat.max_username_length integer Max callsign length
chat.poll_interval_ms integer Client poll interval (ms)
+ chat.poll_timeout_ms integer Abort and retry a stalled poll after this many milliseconds
chat.session_lock_ip bool Lock session to IP
voice.enabled bool Show voice recording controls
voice.max_duration_seconds integer Maximum 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
-
+