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 = '