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