From ae0fb45c483b36081bcf122da16464cf5ef4a756 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Tue, 9 Jun 2026 01:35:19 -0400 Subject: [PATCH] - Fixed the actual race: Sent messages wait for polling to catch up before rendering. Missing interleaved messages cannot appear above later ones. Messages sort by server timestamp, then ID. Timestamps are assigned atomically during SQLite insertion. Client updated to 20260609.5. --- README.md | 2 +- api/messages.php | 30 +++++++++++++++++++++--------- assets/js/cyberchat-app.js | 32 +++++++++++++++++++++++++------- embed-example.html | 4 ++-- index.html | 2 +- 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 229fc72..dd6c208 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 17ce932..607493a 100644 --- a/api/messages.php +++ b/api/messages.php @@ -38,15 +38,18 @@ function sendTextMessage(): never { if ($message === '') jsonResponse(['error' => 'Empty message'], 400); if (strlen($message) > $max) jsonResponse(['error' => "Message too long (max $max chars)"], 400); - $created = time(); $db = getDB(); $db->prepare("INSERT INTO messages (user_id,username,color,message,created_at,day_key) - VALUES (?,?,?,?,?,?)")->execute([ - $user['id'], $user['username'], $user['color'], $message, $created, dayKey(), + VALUES (?,?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([ + $user['id'], $user['username'], $user['color'], $message, dayKey(), ]); + $messageId = (int)$db->lastInsertId(); + $createdStmt = $db->prepare("SELECT created_at FROM messages WHERE id=?"); + $createdStmt->execute([$messageId]); + $created = (int)$createdStmt->fetchColumn(); trackEvent('message_text', '/api/messages.php', (int)$user['id']); jsonResponse(['success' => true, 'message' => messagePayload([ - 'id' => $db->lastInsertId(), + 'id' => $messageId, 'user_id' => $user['id'], 'username' => $user['username'], 'color' => $user['color'], @@ -99,25 +102,28 @@ function sendVoiceMessage(): never { jsonResponse(['error' => 'Could not store voice clip'], 500); } - $created = time(); $storedMime = $mime === 'video/webm' ? 'audio/webm' : $mime; $db = getDB(); try { $db->prepare("INSERT INTO messages (user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key) - VALUES (?,?,?,?,'voice',?,?,?,?,?)")->execute([ + VALUES (?,?,?,?,'voice',?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([ $user['id'], $user['username'], $user['color'], '[Voice clip]', - $filename, $storedMime, $duration, $created, dayKey(), + $filename, $storedMime, $duration, dayKey(), ]); } catch (Throwable $e) { deleteVoiceFile($filename); throw $e; } + $messageId = (int)$db->lastInsertId(); $db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?") ->execute([$user['id']]); + $createdStmt = $db->prepare("SELECT created_at FROM messages WHERE id=?"); + $createdStmt->execute([$messageId]); + $created = (int)$createdStmt->fetchColumn(); trackEvent('message_voice', '/api/messages.php', (int)$user['id']); jsonResponse(['success' => true, 'message' => messagePayload([ - 'id' => $db->lastInsertId(), + 'id' => $messageId, 'user_id' => $user['id'], 'username' => $user['username'], 'color' => $user['color'], @@ -159,7 +165,8 @@ function pollMessages(): never { $stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? ORDER BY id DESC LIMIT ?"); $stmt->execute([dayKey(), $limit]); - $rows = array_reverse($stmt->fetchAll()); + $rows = $stmt->fetchAll(); + usort($rows, 'compareMessagesChronologically'); $hasMore = false; } else { $stmt = $db->prepare("SELECT * FROM messages @@ -197,6 +204,11 @@ function pollMessages(): never { ]); } +function compareMessagesChronologically(array $a, array $b): int { + return ((int)$a['created_at'] <=> (int)$b['created_at']) + ?: ((int)$a['id'] <=> (int)$b['id']); +} + function archiveDays(): never { $user = authRequired(); $rows = personalArchiveRows($user, '', 1000); diff --git a/assets/js/cyberchat-app.js b/assets/js/cyberchat-app.js index f6b0df4..7aba904 100644 --- a/assets/js/cyberchat-app.js +++ b/assets/js/cyberchat-app.js @@ -9,7 +9,7 @@ cursor: 0, poll: { generation: 0, active: false, controller: null, wakeTimer: null, wakeResolve: null, - failures: 0, immediate: false, messages: new Map(), initial: true + failures: 0, immediate: false, messages: new Map(), pendingThrough: 0, initial: true }, loginChallenge: initialChallenge, verifyToken: new URLSearchParams(location.search).get('verify') || '', @@ -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); } - mergeMessages([result.message], false); + acknowledgeSentMessage(result.message); requestPoll(); } function startPoll() { @@ -356,6 +356,7 @@ state.poll.immediate = true; state.poll.initial = true; state.poll.messages.clear(); + state.poll.pendingThrough = 0; void pollWorker(state.poll.generation); } function requestPoll() { @@ -376,6 +377,7 @@ if (wake) wake(); state.poll.immediate = false; state.poll.messages.clear(); + state.poll.pendingThrough = 0; resetVoicePlaybackQueue(); } async function pollWorker(generation) { @@ -439,6 +441,7 @@ state.poll.initial = false; const nextCursor = Math.max(requestedSince, merged.maxId); state.cursor = Math.max(state.cursor, nextCursor); + if (state.cursor >= state.poll.pendingThrough) state.poll.pendingThrough = 0; const recording = $('#recording'); if (recording) { recording.hidden = !result.recording?.length; @@ -447,7 +450,12 @@ + (result.recording.length === 1 ? ' is' : ' are') + ' recording...' : ''; } - return { ok: true, hasMore: Boolean(result.has_more) && nextCursor > requestedSince }; + const madeProgress = nextCursor > requestedSince; + const catchingUpToSend = state.poll.pendingThrough > nextCursor; + return { + ok: true, + hasMore: madeProgress && (Boolean(result.has_more) || catchingUpToSend), + }; } catch (error) { if (state.poll.active && state.poll.generation === generation) { $('#cc-conn-dot').className = 'cc-conn-dot offline'; @@ -463,6 +471,12 @@ if (!Number.isSafeInteger(id) || id < 1) return null; return { ...message, id }; } + function acknowledgeSentMessage(message) { + const acknowledged = normalizeMessage(message); + if (!acknowledged) return; + if (state.poll.messages.has(acknowledged.id) || acknowledged.id <= state.cursor) return; + state.poll.pendingThrough = Math.max(state.poll.pendingThrough, acknowledged.id); + } function isOwnMessage(message) { const userId = Number(message?.user_id); return Number.isSafeInteger(userId) && userId > 0 @@ -476,6 +490,9 @@ message.voice_duration, message.created_at ]); } + function compareMessagesChronologically(a, b) { + return (Number(a.created_at) || 0) - (Number(b.created_at) || 0) || a.id - b.id; + } function mergeMessages(messages, incoming) { const incomingIds = new Set(); let maxId = state.cursor; @@ -495,7 +512,7 @@ if (!list) return; trimMessageStore(); list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove(); - const sorted = [...state.poll.messages.values()].sort((a, b) => a.id - b.id); + const sorted = [...state.poll.messages.values()].sort(compareMessagesChronologically); const nodes = new Map(); Array.from(list.querySelectorAll('.cc-msg')).forEach(item => { const id = Number(item.dataset.id); @@ -558,8 +575,9 @@ } function trimMessageStore() { const limit = Math.max(20, Number(state.config.chat?.max_rendered_messages) || 500); - 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)); + const messages = [...state.poll.messages.values()].sort(compareMessagesChronologically); + messages.slice(0, Math.max(0, messages.length - limit)) + .forEach(message => state.poll.messages.delete(message.id)); } function enqueueVoiceAutoplay(item) { if (!feature('auto_voice_play') || !state.voice.autoPlay) return; @@ -690,7 +708,7 @@ .catch(() => ({ error: 'Voice send failed. Polling will continue.' })); state.voice.sending = false; if (result.error) return toast(result.error); - mergeMessages([result.message], false); + acknowledgeSentMessage(result.message); discardVoice(); requestPoll(); } diff --git a/embed-example.html b/embed-example.html index 5a9a367..afa2c40 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.4"></script> +<script src="assets/js/cyberchat-app.js?v=20260609.5"></script> <script> window.CYBERCHAT_BASE = '/path/to/cyberchat'; // adjust to your install path CyberChat.init('#my-chat'); @@ -104,7 +104,7 @@ - + - +