- Fixed the polling/display system:
One ID-keyed message ledger now handles sends and polls. Messages are always sorted by server ID, latest at bottom. Duplicate DOM rows and voice players are removed. Poll cursors advance only from validated received messages. Voice autoplay queues each received clip once. Added exact user_id ownership tracking. Cache-busted client to 20260609.4.
This commit is contained in:
@@ -143,7 +143,7 @@ Set a PDO MySQL DSN in the dashboard, enable the mirror, and optionally enable a
|
|||||||
<script>
|
<script>
|
||||||
window.CYBERCHAT_BASE = '/chat';
|
window.CYBERCHAT_BASE = '/chat';
|
||||||
</script>
|
</script>
|
||||||
<script src="/chat/assets/js/cyberchat-app.js?v=20260609.3"></script>
|
<script src="/chat/assets/js/cyberchat-app.js?v=20260609.4"></script>
|
||||||
<script>
|
<script>
|
||||||
CyberChat.init('#my-chat');
|
CyberChat.init('#my-chat');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ match ($action) {
|
|||||||
function messagePayload(array $row): array {
|
function messagePayload(array $row): array {
|
||||||
return [
|
return [
|
||||||
'id' => (int)$row['id'],
|
'id' => (int)$row['id'],
|
||||||
|
'user_id' => isset($row['user_id']) ? (int)$row['user_id'] : null,
|
||||||
'username' => $row['username'],
|
'username' => $row['username'],
|
||||||
'color' => $row['color'],
|
'color' => $row['color'],
|
||||||
'message' => $row['message'],
|
'message' => $row['message'],
|
||||||
@@ -46,6 +47,7 @@ function sendTextMessage(): never {
|
|||||||
trackEvent('message_text', '/api/messages.php', (int)$user['id']);
|
trackEvent('message_text', '/api/messages.php', (int)$user['id']);
|
||||||
jsonResponse(['success' => true, 'message' => messagePayload([
|
jsonResponse(['success' => true, 'message' => messagePayload([
|
||||||
'id' => $db->lastInsertId(),
|
'id' => $db->lastInsertId(),
|
||||||
|
'user_id' => $user['id'],
|
||||||
'username' => $user['username'],
|
'username' => $user['username'],
|
||||||
'color' => $user['color'],
|
'color' => $user['color'],
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
@@ -116,6 +118,7 @@ function sendVoiceMessage(): never {
|
|||||||
trackEvent('message_voice', '/api/messages.php', (int)$user['id']);
|
trackEvent('message_voice', '/api/messages.php', (int)$user['id']);
|
||||||
jsonResponse(['success' => true, 'message' => messagePayload([
|
jsonResponse(['success' => true, 'message' => messagePayload([
|
||||||
'id' => $db->lastInsertId(),
|
'id' => $db->lastInsertId(),
|
||||||
|
'user_id' => $user['id'],
|
||||||
'username' => $user['username'],
|
'username' => $user['username'],
|
||||||
'color' => $user['color'],
|
'color' => $user['color'],
|
||||||
'message' => '[Voice clip]',
|
'message' => '[Voice clip]',
|
||||||
|
|||||||
+104
-59
@@ -9,13 +9,13 @@
|
|||||||
cursor: 0,
|
cursor: 0,
|
||||||
poll: {
|
poll: {
|
||||||
generation: 0, active: false, controller: null, wakeTimer: null, wakeResolve: null,
|
generation: 0, active: false, controller: null, wakeTimer: null, wakeResolve: null,
|
||||||
failures: 0, immediate: false, queue: [], queuedIds: new Set(), initial: true
|
failures: 0, immediate: false, messages: new Map(), initial: true
|
||||||
},
|
},
|
||||||
loginChallenge: initialChallenge,
|
loginChallenge: initialChallenge,
|
||||||
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
||||||
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
|
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
|
||||||
chunks: [], blob: null, duration: 0, url: '', autoSend: false, autoPlay: false,
|
chunks: [], blob: null, duration: 0, url: '', autoSend: false, autoPlay: false,
|
||||||
playQueue: [], queuePlaying: false, autoplayWarningShown: false, sending: false }
|
playQueue: [], currentAudio: null, queuePlaying: false, autoplayWarningShown: false, sending: false }
|
||||||
};
|
};
|
||||||
let root;
|
let root;
|
||||||
let wakeListenersBound = false;
|
let wakeListenersBound = false;
|
||||||
@@ -345,7 +345,7 @@
|
|||||||
const result = await api('messages.php', form({ action: 'send', message }))
|
const result = await api('messages.php', form({ action: 'send', message }))
|
||||||
.catch(() => ({ error: 'Message send failed. Polling will continue.' }));
|
.catch(() => ({ error: 'Message send failed. Polling will continue.' }));
|
||||||
if (result.error) { input.value = message; return toast(result.error); }
|
if (result.error) { input.value = message; return toast(result.error); }
|
||||||
appendMessage(result.message);
|
mergeMessages([result.message], false);
|
||||||
requestPoll();
|
requestPoll();
|
||||||
}
|
}
|
||||||
function startPoll() {
|
function startPoll() {
|
||||||
@@ -355,8 +355,7 @@
|
|||||||
state.poll.failures = 0;
|
state.poll.failures = 0;
|
||||||
state.poll.immediate = true;
|
state.poll.immediate = true;
|
||||||
state.poll.initial = true;
|
state.poll.initial = true;
|
||||||
state.poll.queue = [];
|
state.poll.messages.clear();
|
||||||
state.poll.queuedIds.clear();
|
|
||||||
void pollWorker(state.poll.generation);
|
void pollWorker(state.poll.generation);
|
||||||
}
|
}
|
||||||
function requestPoll() {
|
function requestPoll() {
|
||||||
@@ -376,8 +375,8 @@
|
|||||||
state.poll.wakeResolve = null;
|
state.poll.wakeResolve = null;
|
||||||
if (wake) wake();
|
if (wake) wake();
|
||||||
state.poll.immediate = false;
|
state.poll.immediate = false;
|
||||||
state.poll.queue = [];
|
state.poll.messages.clear();
|
||||||
state.poll.queuedIds.clear();
|
resetVoicePlaybackQueue();
|
||||||
}
|
}
|
||||||
async function pollWorker(generation) {
|
async function pollWorker(generation) {
|
||||||
const interval = Math.max(250, Number(state.config.chat?.poll_interval_ms) || 2000);
|
const interval = Math.max(250, Number(state.config.chat?.poll_interval_ms) || 2000);
|
||||||
@@ -436,13 +435,9 @@
|
|||||||
$('#cc-conn-dot').className = 'cc-conn-dot online';
|
$('#cc-conn-dot').className = 'cc-conn-dot online';
|
||||||
$('#cc-online-count').textContent = result.online;
|
$('#cc-online-count').textContent = result.online;
|
||||||
const initial = state.poll.initial;
|
const initial = state.poll.initial;
|
||||||
enqueuePollMessages(result.messages, !initial);
|
const merged = mergeMessages(result.messages, !initial);
|
||||||
state.poll.initial = false;
|
state.poll.initial = false;
|
||||||
const messageCursor = result.messages.reduce(
|
const nextCursor = Math.max(requestedSince, merged.maxId);
|
||||||
(cursor, message) => Math.max(cursor, Number(message.id) || 0),
|
|
||||||
requestedSince
|
|
||||||
);
|
|
||||||
const nextCursor = Math.max(requestedSince, Number(result.cursor) || 0, messageCursor);
|
|
||||||
state.cursor = Math.max(state.cursor, nextCursor);
|
state.cursor = Math.max(state.cursor, nextCursor);
|
||||||
const recording = $('#recording');
|
const recording = $('#recording');
|
||||||
if (recording) {
|
if (recording) {
|
||||||
@@ -463,69 +458,108 @@
|
|||||||
if (state.poll.controller === controller) state.poll.controller = null;
|
if (state.poll.controller === controller) state.poll.controller = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function enqueuePollMessages(messages, incoming) {
|
function normalizeMessage(message) {
|
||||||
|
const id = Number(message?.id);
|
||||||
|
if (!Number.isSafeInteger(id) || id < 1) return null;
|
||||||
|
return { ...message, id };
|
||||||
|
}
|
||||||
|
function isOwnMessage(message) {
|
||||||
|
const userId = Number(message?.user_id);
|
||||||
|
return Number.isSafeInteger(userId) && userId > 0
|
||||||
|
? userId === Number(state.user?.id)
|
||||||
|
: message?.username === state.user?.username;
|
||||||
|
}
|
||||||
|
function messageRenderKey(message) {
|
||||||
|
return JSON.stringify([
|
||||||
|
message.user_id ?? null, message.username, message.color, message.message,
|
||||||
|
message.message_type, message.voice_url, message.voice_mime,
|
||||||
|
message.voice_duration, message.created_at
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function mergeMessages(messages, incoming) {
|
||||||
|
const incomingIds = new Set();
|
||||||
|
let maxId = state.cursor;
|
||||||
|
for (const rawMessage of Array.isArray(messages) ? messages : []) {
|
||||||
|
const message = normalizeMessage(rawMessage);
|
||||||
|
if (!message) continue;
|
||||||
|
maxId = Math.max(maxId, message.id);
|
||||||
|
const previous = state.poll.messages.get(message.id);
|
||||||
|
state.poll.messages.set(message.id, previous ? { ...previous, ...message } : message);
|
||||||
|
if (!previous && incoming) incomingIds.add(message.id);
|
||||||
|
}
|
||||||
|
reconcileMessages(incomingIds);
|
||||||
|
return { maxId };
|
||||||
|
}
|
||||||
|
function reconcileMessages(incomingIds = new Set()) {
|
||||||
const list = $('#messages');
|
const list = $('#messages');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const sorted = [...messages].sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0));
|
trimMessageStore();
|
||||||
sorted.forEach(message => {
|
list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove();
|
||||||
const numericId = Number(message.id);
|
const sorted = [...state.poll.messages.values()].sort((a, b) => a.id - b.id);
|
||||||
if (!Number.isInteger(numericId) || numericId < 1) return;
|
const nodes = new Map();
|
||||||
const id = String(numericId);
|
Array.from(list.querySelectorAll('.cc-msg')).forEach(item => {
|
||||||
if (state.poll.queuedIds.has(id) || list.querySelector(`[data-id="${id}"]`)) return;
|
const id = Number(item.dataset.id);
|
||||||
state.poll.queuedIds.add(id);
|
if (!Number.isSafeInteger(id) || nodes.has(id)) {
|
||||||
state.poll.queue.push({ message, incoming });
|
$('audio', item)?.pause();
|
||||||
|
item.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nodes.set(id, item);
|
||||||
});
|
});
|
||||||
drainPollQueue();
|
for (const message of sorted) {
|
||||||
if (!list.querySelector('.cc-msg') && state.poll.queue.length === 0) {
|
const renderKey = messageRenderKey(message);
|
||||||
|
let item = nodes.get(message.id);
|
||||||
|
if (!item || item.dataset.renderKey !== renderKey) {
|
||||||
|
const replacement = createMessageElement(message);
|
||||||
|
if (item) {
|
||||||
|
$('audio', item)?.pause();
|
||||||
|
item.replaceWith(replacement);
|
||||||
|
}
|
||||||
|
item = replacement;
|
||||||
|
nodes.set(message.id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const retainedIds = new Set(sorted.map(message => message.id));
|
||||||
|
nodes.forEach((item, id) => {
|
||||||
|
if (retainedIds.has(id)) return;
|
||||||
|
$('audio', item)?.pause();
|
||||||
|
item.remove();
|
||||||
|
});
|
||||||
|
const currentOrder = Array.from(list.querySelectorAll('.cc-msg')).map(item => Number(item.dataset.id));
|
||||||
|
const desiredOrder = sorted.map(message => message.id);
|
||||||
|
if (currentOrder.length !== desiredOrder.length
|
||||||
|
|| currentOrder.some((id, index) => id !== desiredOrder[index])) {
|
||||||
|
desiredOrder.forEach(id => list.appendChild(nodes.get(id)));
|
||||||
|
}
|
||||||
|
sorted.forEach(message => {
|
||||||
|
if (!incomingIds.has(message.id) || isOwnMessage(message)) return;
|
||||||
|
const item = nodes.get(message.id);
|
||||||
|
if (message.message_type === 'voice' && item) enqueueVoiceAutoplay(item);
|
||||||
|
});
|
||||||
|
if (!sorted.length) {
|
||||||
list.innerHTML = '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
|
list.innerHTML = '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
|
||||||
}
|
}
|
||||||
list.scrollTop = list.scrollHeight;
|
list.scrollTop = list.scrollHeight;
|
||||||
}
|
}
|
||||||
function drainPollQueue() {
|
function createMessageElement(message) {
|
||||||
while (state.poll.queue.length) {
|
const id = message.id;
|
||||||
const entry = state.poll.queue.shift();
|
|
||||||
const id = String(entry.message?.id ?? '');
|
|
||||||
try {
|
|
||||||
appendMessage(entry.message, entry.incoming);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Could not render chat message', error);
|
|
||||||
} finally {
|
|
||||||
state.poll.queuedIds.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function appendMessage(message, incoming = false) {
|
|
||||||
const list = $('#messages');
|
|
||||||
const id = Number(message?.id);
|
|
||||||
if (!list || !Number.isInteger(id) || id < 1 || list.querySelector(`[data-id="${id}"]`)) return;
|
|
||||||
list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove();
|
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'cc-msg' + (message.username === state.user.username ? ' cc-msg-own' : '') + ' cc-msg-new';
|
item.className = 'cc-msg' + (isOwnMessage(message) ? ' cc-msg-own' : '') + ' cc-msg-new';
|
||||||
item.dataset.id = String(id);
|
item.dataset.id = String(id);
|
||||||
|
item.dataset.renderKey = messageRenderKey(message);
|
||||||
const content = message.message_type === 'voice' && message.voice_url
|
const content = message.message_type === 'voice' && message.voice_url
|
||||||
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url))}</span>`
|
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url))}</span>`
|
||||||
: linkify(esc(message.message));
|
: linkify(esc(message.message));
|
||||||
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span>
|
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span>
|
||||||
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
|
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
|
||||||
<span class="cc-msg-sep">></span><span class="cc-msg-text">${content}</span>`;
|
<span class="cc-msg-sep">></span><span class="cc-msg-text">${content}</span>`;
|
||||||
const nextMessage = Array.from(list.querySelectorAll('.cc-msg'))
|
|
||||||
.find(existing => Number(existing.dataset.id) > id);
|
|
||||||
if (nextMessage) list.insertBefore(item, nextMessage);
|
|
||||||
else list.appendChild(item);
|
|
||||||
trimMessageBuffer(list);
|
|
||||||
bindAudioPlayers(item);
|
bindAudioPlayers(item);
|
||||||
if (incoming && message.message_type === 'voice' && message.username !== state.user.username) {
|
return item;
|
||||||
enqueueVoiceAutoplay(item);
|
|
||||||
}
|
|
||||||
list.scrollTop = list.scrollHeight;
|
|
||||||
}
|
}
|
||||||
function trimMessageBuffer(list) {
|
function trimMessageStore() {
|
||||||
const limit = Math.max(20, Number(state.config.chat?.max_rendered_messages) || 500);
|
const limit = Math.max(20, Number(state.config.chat?.max_rendered_messages) || 500);
|
||||||
const messages = Array.from(list.querySelectorAll('.cc-msg'));
|
const ids = [...state.poll.messages.keys()].sort((a, b) => a - b);
|
||||||
messages.slice(0, Math.max(0, messages.length - limit)).forEach(message => {
|
ids.slice(0, Math.max(0, ids.length - limit)).forEach(id => state.poll.messages.delete(id));
|
||||||
$('audio', message)?.pause();
|
|
||||||
message.remove();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
function enqueueVoiceAutoplay(item) {
|
function enqueueVoiceAutoplay(item) {
|
||||||
if (!feature('auto_voice_play') || !state.voice.autoPlay) return;
|
if (!feature('auto_voice_play') || !state.voice.autoPlay) return;
|
||||||
@@ -537,13 +571,16 @@
|
|||||||
function playNextQueuedVoice() {
|
function playNextQueuedVoice() {
|
||||||
if (state.voice.queuePlaying || !state.voice.playQueue.length) return;
|
if (state.voice.queuePlaying || !state.voice.playQueue.length) return;
|
||||||
const audio = state.voice.playQueue.shift();
|
const audio = state.voice.playQueue.shift();
|
||||||
|
if (!audio?.isConnected) return playNextQueuedVoice();
|
||||||
state.voice.queuePlaying = true;
|
state.voice.queuePlaying = true;
|
||||||
|
state.voice.currentAudio = audio;
|
||||||
let started = false;
|
let started = false;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const done = () => {
|
const done = () => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
state.voice.queuePlaying = false;
|
state.voice.queuePlaying = false;
|
||||||
|
if (state.voice.currentAudio === audio) state.voice.currentAudio = null;
|
||||||
playNextQueuedVoice();
|
playNextQueuedVoice();
|
||||||
};
|
};
|
||||||
audio.addEventListener('playing', () => { started = true; }, { once: true });
|
audio.addEventListener('playing', () => { started = true; }, { once: true });
|
||||||
@@ -555,6 +592,7 @@
|
|||||||
audio.play().catch(() => {
|
audio.play().catch(() => {
|
||||||
settled = true;
|
settled = true;
|
||||||
state.voice.queuePlaying = false;
|
state.voice.queuePlaying = false;
|
||||||
|
if (state.voice.currentAudio === audio) state.voice.currentAudio = null;
|
||||||
state.voice.playQueue = [];
|
state.voice.playQueue = [];
|
||||||
if (!state.voice.autoplayWarningShown) {
|
if (!state.voice.autoplayWarningShown) {
|
||||||
state.voice.autoplayWarningShown = true;
|
state.voice.autoplayWarningShown = true;
|
||||||
@@ -562,6 +600,13 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function resetVoicePlaybackQueue() {
|
||||||
|
const current = state.voice.currentAudio;
|
||||||
|
state.voice.playQueue = [];
|
||||||
|
state.voice.currentAudio = null;
|
||||||
|
state.voice.queuePlaying = false;
|
||||||
|
if (current && !current.paused) current.pause();
|
||||||
|
}
|
||||||
|
|
||||||
async function recording(active) {
|
async function recording(active) {
|
||||||
if (!feature('recording_status')) return;
|
if (!feature('recording_status')) return;
|
||||||
@@ -645,7 +690,7 @@
|
|||||||
.catch(() => ({ error: 'Voice send failed. Polling will continue.' }));
|
.catch(() => ({ error: 'Voice send failed. Polling will continue.' }));
|
||||||
state.voice.sending = false;
|
state.voice.sending = false;
|
||||||
if (result.error) return toast(result.error);
|
if (result.error) return toast(result.error);
|
||||||
appendMessage(result.message);
|
mergeMessages([result.message], false);
|
||||||
discardVoice();
|
discardVoice();
|
||||||
requestPoll();
|
requestPoll();
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -94,7 +94,7 @@
|
|||||||
<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-app.js?v=20260609.3"</span><span class="kw">></script></span>
|
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.4"</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-app.js?v=20260609.3"></script>
|
<script src="assets/js/cyberchat-app.js?v=20260609.4"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
CyberChat.init('#chat-here');
|
CyberChat.init('#chat-here');
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
<script>
|
<script>
|
||||||
window.CYBERCHAT_BASE = '';
|
window.CYBERCHAT_BASE = '';
|
||||||
</script>
|
</script>
|
||||||
<script src="assets/js/cyberchat-app.js?v=20260609.3"></script>
|
<script src="assets/js/cyberchat-app.js?v=20260609.4"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
CyberChat.init('#app');
|
CyberChat.init('#app');
|
||||||
|
|||||||
Reference in New Issue
Block a user