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