-
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
|
- Premium-configurable automatic voice sending
|
||||||
- Tier-configurable automatic playback of newly received voice clips
|
- Tier-configurable automatic playback of newly received voice clips
|
||||||
- Administrator tier overrides that grant plan features without payment
|
- Administrator tier overrides that grant plan features without payment
|
||||||
|
- Resilient polling with stalled-request timeouts and post-send reconciliation
|
||||||
- Plan-based custom username colors
|
- Plan-based custom username colors
|
||||||
- Personal text archives retained for at least 30 days
|
- Personal text archives retained for at least 30 days
|
||||||
- Plan-based voice archive access and JSON/CSV exports
|
- 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_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.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_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">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.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>
|
<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_message_length' => $config['chat']['max_message_length'] ?? 500,
|
||||||
'max_username_length' => $config['chat']['max_username_length'] ?? 12,
|
'max_username_length' => $config['chat']['max_username_length'] ?? 12,
|
||||||
'poll_interval_ms' => $config['chat']['poll_interval_ms'] ?? 2000,
|
'poll_interval_ms' => $config['chat']['poll_interval_ms'] ?? 2000,
|
||||||
|
'poll_timeout_ms' => $config['chat']['poll_timeout_ms'] ?? 12000,
|
||||||
],
|
],
|
||||||
'ui' => $config['ui'] ?? [],
|
'ui' => $config['ui'] ?? [],
|
||||||
'security' => ['min_password_length' => $config['security']['min_password_length'] ?? 4],
|
'security' => ['min_password_length' => $config['security']['min_password_length'] ?? 4],
|
||||||
|
|||||||
+52
-12
@@ -6,7 +6,8 @@
|
|||||||
const state = {
|
const state = {
|
||||||
config: {}, user: null, groups: [], billing: null,
|
config: {}, user: null, groups: [], billing: null,
|
||||||
view: ['chat', 'groups', 'archive', 'account'].includes(initialView) ? initialView : 'chat',
|
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: {},
|
groupSeen: {}, groupUnread: {},
|
||||||
loginChallenge: initialChallenge,
|
loginChallenge: initialChallenge,
|
||||||
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
||||||
@@ -37,7 +38,11 @@
|
|||||||
return { method: 'POST', body };
|
return { method: 'POST', body };
|
||||||
}
|
}
|
||||||
async function api(file, options, query = '') {
|
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' }));
|
const data = await response.json().catch(() => ({ error: 'The server returned an invalid response' }));
|
||||||
if (!response.ok && !data.error) data.error = 'Request failed';
|
if (!response.ok && !data.error) data.error = 'Request failed';
|
||||||
return data;
|
return data;
|
||||||
@@ -383,35 +388,63 @@
|
|||||||
const message = input.value.trim();
|
const message = input.value.trim();
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
input.value = '';
|
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) {
|
if (result.groups_enabled === false) {
|
||||||
input.value = message;
|
input.value = message;
|
||||||
return disableGroups(result.error, result.groups_platform_enabled !== false);
|
return disableGroups(result.error, result.groups_platform_enabled !== false);
|
||||||
}
|
}
|
||||||
if (result.error) { input.value = message; return toast(result.error); }
|
if (result.error) { input.value = message; return toast(result.error); }
|
||||||
appendMessage(result.message);
|
appendMessage(result.message);
|
||||||
state.lastId = Math.max(state.lastId, result.message.id);
|
requestPoll();
|
||||||
}
|
}
|
||||||
function startPoll() {
|
function startPoll() {
|
||||||
stopPoll();
|
stopPoll();
|
||||||
const generation = state.pollGeneration;
|
const generation = state.pollGeneration;
|
||||||
poll(generation);
|
schedulePoll(generation, 0);
|
||||||
state.pollTimer = setInterval(() => poll(generation), state.config.chat?.poll_interval_ms || 2000);
|
}
|
||||||
|
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() {
|
function stopPoll() {
|
||||||
if (state.pollTimer) clearInterval(state.pollTimer);
|
if (state.pollTimer) clearTimeout(state.pollTimer);
|
||||||
|
state.pollController?.abort();
|
||||||
state.pollTimer = null;
|
state.pollTimer = null;
|
||||||
|
state.pollController = null;
|
||||||
state.polling = false;
|
state.polling = false;
|
||||||
|
state.pollRequested = false;
|
||||||
state.pollGeneration++;
|
state.pollGeneration++;
|
||||||
}
|
}
|
||||||
async function poll(generation = state.pollGeneration) {
|
async function poll(generation = state.pollGeneration) {
|
||||||
if (state.polling || state.view !== 'chat' || generation !== state.pollGeneration) return;
|
if (state.polling || state.view !== 'chat' || generation !== state.pollGeneration) return;
|
||||||
const requestedGroup = state.groupId;
|
const requestedGroup = state.groupId;
|
||||||
const requestedSince = state.lastId;
|
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;
|
state.polling = true;
|
||||||
try {
|
try {
|
||||||
const query = new URLSearchParams({ action: 'poll', since: requestedSince, group_id: requestedGroup || '' });
|
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 (generation !== state.pollGeneration || Number(state.groupId || 0) !== Number(requestedGroup || 0)) return;
|
||||||
if (result && !result.error && Number(result.group_id || 0) === Number(requestedGroup || 0)) {
|
if (result && !result.error && Number(result.group_id || 0) === Number(requestedGroup || 0)) {
|
||||||
const availabilityChanged = applyGroupsAvailability(
|
const availabilityChanged = applyGroupsAvailability(
|
||||||
@@ -429,7 +462,9 @@
|
|||||||
if (groupsAvailable()) syncGroups(result.groups, true);
|
if (groupsAvailable()) syncGroups(result.groups, true);
|
||||||
if (requestedSince === 0) renderMessages(result.messages || []);
|
if (requestedSince === 0) renderMessages(result.messages || []);
|
||||||
else (result.messages || []).forEach(message => appendMessage(message, true));
|
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) {
|
if (requestedGroup !== null) {
|
||||||
state.groupUnread[requestedGroup] = false;
|
state.groupUnread[requestedGroup] = false;
|
||||||
state.groupSeen[requestedGroup] = Math.max(Number(state.groupSeen[requestedGroup] || 0), Number(state.lastId || 0));
|
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);
|
if (result?.error && requestedGroup !== null) await recoverGroupAccess(requestedGroup, generation);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (state.pollController === controller) state.pollController = null;
|
||||||
if (generation === state.pollGeneration) state.polling = false;
|
if (generation === state.pollGeneration) state.polling = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -470,7 +507,9 @@
|
|||||||
const list = $('#messages');
|
const list = $('#messages');
|
||||||
list.innerHTML = messages.length ? '' : '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
|
list.innerHTML = messages.length ? '' : '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
|
||||||
messages.forEach(appendMessage);
|
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;
|
list.scrollTop = list.scrollHeight;
|
||||||
}
|
}
|
||||||
function appendMessage(message, incoming = false) {
|
function appendMessage(message, incoming = false) {
|
||||||
@@ -608,13 +647,14 @@
|
|||||||
body.append('group_id', state.groupId || '');
|
body.append('group_id', state.groupId || '');
|
||||||
body.append('duration', state.voice.duration.toFixed(2));
|
body.append('duration', state.voice.duration.toFixed(2));
|
||||||
body.append('voice', state.voice.blob, 'voice.webm');
|
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;
|
state.voice.sending = false;
|
||||||
if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
|
if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
|
||||||
if (result.error) return toast(result.error);
|
if (result.error) return toast(result.error);
|
||||||
appendMessage(result.message);
|
appendMessage(result.message);
|
||||||
state.lastId = Math.max(state.lastId, result.message.id);
|
|
||||||
discardVoice();
|
discardVoice();
|
||||||
|
requestPoll();
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupsView() {
|
function groupsView() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"max_message_length": 500,
|
"max_message_length": 500,
|
||||||
"max_username_length": 12,
|
"max_username_length": 12,
|
||||||
"poll_interval_ms": 2000,
|
"poll_interval_ms": 2000,
|
||||||
|
"poll_timeout_ms": 12000,
|
||||||
"messages_per_page": 100,
|
"messages_per_page": 100,
|
||||||
"session_lock_ip": true,
|
"session_lock_ip": true,
|
||||||
"session_lock_cookie": true,
|
"session_lock_cookie": true,
|
||||||
|
|||||||
+4
-4
@@ -6,7 +6,7 @@
|
|||||||
<title>CyberChat — Embed Example</title>
|
<title>CyberChat — Embed Example</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<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 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>
|
<style>
|
||||||
html, body {
|
html, body {
|
||||||
height: auto;
|
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="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="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="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="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="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>
|
<span class="kw"><script></span>
|
||||||
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</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>);
|
CyberChat.init(<span class="str">'#my-chat'</span>);
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
<script>
|
<script>
|
||||||
window.CYBERCHAT_BASE = '';
|
window.CYBERCHAT_BASE = '';
|
||||||
</script>
|
</script>
|
||||||
<script src="assets/js/cyberchat-v4.js?v=20260608.4"></script>
|
<script src="assets/js/cyberchat-v4.js?v=20260608.5"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
CyberChat.init('#chat-here');
|
CyberChat.init('#chat-here');
|
||||||
|
|||||||
+2
-2
@@ -7,14 +7,14 @@
|
|||||||
<title>Chat @ TyClifford.com</title>
|
<title>Chat @ TyClifford.com</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<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 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script>
|
<script>
|
||||||
window.CYBERCHAT_BASE = '';
|
window.CYBERCHAT_BASE = '';
|
||||||
</script>
|
</script>
|
||||||
<script src="assets/js/cyberchat-v4.js?v=20260608.4"></script>
|
<script src="assets/js/cyberchat-v4.js?v=20260608.5"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
CyberChat.init('#app');
|
CyberChat.init('#app');
|
||||||
|
|||||||
Reference in New Issue
Block a user