-
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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"><link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600;700&family=Orbitron:wght@700;900&display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">></span>
|
||||
|
||||
<span class="cm"><!-- 2. CyberChat stylesheet --></span>
|
||||
<span class="kw"><link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260608.4"</span><span class="kw">></span>
|
||||
<span class="kw"><link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260608.5"</span><span class="kw">></span>
|
||||
|
||||
<span class="cm"><!-- 3. Container with any dimensions --></span>
|
||||
<span class="kw"><div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">></div></span>
|
||||
|
||||
<span class="cm"><!-- 4. Script + init --></span>
|
||||
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-v4.js?v=20260608.4"</span><span class="kw">></script></span>
|
||||
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-v4.js?v=20260608.5"</span><span class="kw">></script></span>
|
||||
<span class="kw"><script></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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user