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 @@ - + - +