From 195f3fa56d0bdfa8c24714bcfd0ec5f672594457 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Tue, 9 Jun 2026 01:01:22 -0400 Subject: [PATCH] - 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. --- README.md | 2 +- api/messages.php | 3 + assets/js/cyberchat-app.js | 163 +++++++++++++++++++++++-------------- embed-example.html | 4 +- index.html | 2 +- 5 files changed, 111 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index c519ab8..229fc72 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ Set a PDO MySQL DSN in the dashboard, enable the mirror, and optionally enable a - + diff --git a/api/messages.php b/api/messages.php index e6782e3..17ce932 100644 --- a/api/messages.php +++ b/api/messages.php @@ -18,6 +18,7 @@ match ($action) { function messagePayload(array $row): array { return [ 'id' => (int)$row['id'], + 'user_id' => isset($row['user_id']) ? (int)$row['user_id'] : null, 'username' => $row['username'], 'color' => $row['color'], 'message' => $row['message'], @@ -46,6 +47,7 @@ function sendTextMessage(): never { trackEvent('message_text', '/api/messages.php', (int)$user['id']); jsonResponse(['success' => true, 'message' => messagePayload([ 'id' => $db->lastInsertId(), + 'user_id' => $user['id'], 'username' => $user['username'], 'color' => $user['color'], 'message' => $message, @@ -116,6 +118,7 @@ function sendVoiceMessage(): never { trackEvent('message_voice', '/api/messages.php', (int)$user['id']); jsonResponse(['success' => true, 'message' => messagePayload([ 'id' => $db->lastInsertId(), + 'user_id' => $user['id'], 'username' => $user['username'], 'color' => $user['color'], 'message' => '[Voice clip]', diff --git a/assets/js/cyberchat-app.js b/assets/js/cyberchat-app.js index d22438a..f6b0df4 100644 --- a/assets/js/cyberchat-app.js +++ b/assets/js/cyberchat-app.js @@ -9,13 +9,13 @@ cursor: 0, poll: { 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, verifyToken: new URLSearchParams(location.search).get('verify') || '', voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0, 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 wakeListenersBound = false; @@ -345,7 +345,7 @@ const result = await api('messages.php', form({ action: 'send', message })) .catch(() => ({ error: 'Message send failed. Polling will continue.' })); if (result.error) { input.value = message; return toast(result.error); } - appendMessage(result.message); + mergeMessages([result.message], false); requestPoll(); } function startPoll() { @@ -355,8 +355,7 @@ state.poll.failures = 0; state.poll.immediate = true; state.poll.initial = true; - state.poll.queue = []; - state.poll.queuedIds.clear(); + state.poll.messages.clear(); void pollWorker(state.poll.generation); } function requestPoll() { @@ -376,8 +375,8 @@ state.poll.wakeResolve = null; if (wake) wake(); state.poll.immediate = false; - state.poll.queue = []; - state.poll.queuedIds.clear(); + state.poll.messages.clear(); + resetVoicePlaybackQueue(); } async function pollWorker(generation) { 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-online-count').textContent = result.online; const initial = state.poll.initial; - enqueuePollMessages(result.messages, !initial); + const merged = mergeMessages(result.messages, !initial); state.poll.initial = false; - const messageCursor = result.messages.reduce( - (cursor, message) => Math.max(cursor, Number(message.id) || 0), - requestedSince - ); - const nextCursor = Math.max(requestedSince, Number(result.cursor) || 0, messageCursor); + const nextCursor = Math.max(requestedSince, merged.maxId); state.cursor = Math.max(state.cursor, nextCursor); const recording = $('#recording'); if (recording) { @@ -463,69 +458,108 @@ 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'); if (!list) return; - const sorted = [...messages].sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0)); - sorted.forEach(message => { - const numericId = Number(message.id); - if (!Number.isInteger(numericId) || numericId < 1) return; - const id = String(numericId); - if (state.poll.queuedIds.has(id) || list.querySelector(`[data-id="${id}"]`)) return; - state.poll.queuedIds.add(id); - state.poll.queue.push({ message, incoming }); + trimMessageStore(); + list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove(); + const sorted = [...state.poll.messages.values()].sort((a, b) => a.id - b.id); + const nodes = new Map(); + Array.from(list.querySelectorAll('.cc-msg')).forEach(item => { + const id = Number(item.dataset.id); + if (!Number.isSafeInteger(id) || nodes.has(id)) { + $('audio', item)?.pause(); + item.remove(); + return; + } + nodes.set(id, item); }); - drainPollQueue(); - if (!list.querySelector('.cc-msg') && state.poll.queue.length === 0) { + for (const message of sorted) { + 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 = '
NO MESSAGES IN THIS CHANNEL TODAY
'; } list.scrollTop = list.scrollHeight; } - function drainPollQueue() { - while (state.poll.queue.length) { - 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(); + function createMessageElement(message) { + const id = message.id; 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.renderKey = messageRenderKey(message); const content = message.message_type === 'voice' && message.voice_url ? `VOICE ${duration(message.voice_duration)}${audioPlayer(publicUrl(message.voice_url))}` : linkify(esc(message.message)); item.innerHTML = `${clock(message.created_at)} ${esc(message.username)} >${content}`; - 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); - if (incoming && message.message_type === 'voice' && message.username !== state.user.username) { - enqueueVoiceAutoplay(item); - } - list.scrollTop = list.scrollHeight; + return item; } - function trimMessageBuffer(list) { + function trimMessageStore() { const limit = Math.max(20, Number(state.config.chat?.max_rendered_messages) || 500); - const messages = Array.from(list.querySelectorAll('.cc-msg')); - messages.slice(0, Math.max(0, messages.length - limit)).forEach(message => { - $('audio', message)?.pause(); - message.remove(); - }); + const ids = [...state.poll.messages.keys()].sort((a, b) => a - b); + ids.slice(0, Math.max(0, ids.length - limit)).forEach(id => state.poll.messages.delete(id)); } function enqueueVoiceAutoplay(item) { if (!feature('auto_voice_play') || !state.voice.autoPlay) return; @@ -537,13 +571,16 @@ function playNextQueuedVoice() { if (state.voice.queuePlaying || !state.voice.playQueue.length) return; const audio = state.voice.playQueue.shift(); + if (!audio?.isConnected) return playNextQueuedVoice(); state.voice.queuePlaying = true; + state.voice.currentAudio = audio; let started = false; let settled = false; const done = () => { if (settled) return; settled = true; state.voice.queuePlaying = false; + if (state.voice.currentAudio === audio) state.voice.currentAudio = null; playNextQueuedVoice(); }; audio.addEventListener('playing', () => { started = true; }, { once: true }); @@ -555,6 +592,7 @@ audio.play().catch(() => { settled = true; state.voice.queuePlaying = false; + if (state.voice.currentAudio === audio) state.voice.currentAudio = null; state.voice.playQueue = []; if (!state.voice.autoplayWarningShown) { 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) { if (!feature('recording_status')) return; @@ -645,7 +690,7 @@ .catch(() => ({ error: 'Voice send failed. Polling will continue.' })); state.voice.sending = false; if (result.error) return toast(result.error); - appendMessage(result.message); + mergeMessages([result.message], false); discardVoice(); requestPoll(); } diff --git a/embed-example.html b/embed-example.html index c7389fe..5a9a367 100644 --- a/embed-example.html +++ b/embed-example.html @@ -94,7 +94,7 @@ <div id="my-chat" style="width:100%; height:600px"></div> <!-- 4. Script + init --> -<script src="assets/js/cyberchat-app.js?v=20260609.3"></script> +<script src="assets/js/cyberchat-app.js?v=20260609.4"></script> <script> window.CYBERCHAT_BASE = '/path/to/cyberchat'; // adjust to your install path CyberChat.init('#my-chat'); @@ -104,7 +104,7 @@ - + - +