- 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.
This commit is contained in:
@@ -143,7 +143,7 @@ Set a PDO MySQL DSN in the dashboard, enable the mirror, and optionally enable a
|
|||||||
<script>
|
<script>
|
||||||
window.CYBERCHAT_BASE = '/chat';
|
window.CYBERCHAT_BASE = '/chat';
|
||||||
</script>
|
</script>
|
||||||
<script src="/chat/assets/js/cyberchat-app.js?v=20260609.4"></script>
|
<script src="/chat/assets/js/cyberchat-app.js?v=20260609.5"></script>
|
||||||
<script>
|
<script>
|
||||||
CyberChat.init('#my-chat');
|
CyberChat.init('#my-chat');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+21
-9
@@ -38,15 +38,18 @@ function sendTextMessage(): never {
|
|||||||
if ($message === '') jsonResponse(['error' => 'Empty message'], 400);
|
if ($message === '') jsonResponse(['error' => 'Empty message'], 400);
|
||||||
if (strlen($message) > $max) jsonResponse(['error' => "Message too long (max $max chars)"], 400);
|
if (strlen($message) > $max) jsonResponse(['error' => "Message too long (max $max chars)"], 400);
|
||||||
|
|
||||||
$created = time();
|
|
||||||
$db = getDB();
|
$db = getDB();
|
||||||
$db->prepare("INSERT INTO messages (user_id,username,color,message,created_at,day_key)
|
$db->prepare("INSERT INTO messages (user_id,username,color,message,created_at,day_key)
|
||||||
VALUES (?,?,?,?,?,?)")->execute([
|
VALUES (?,?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([
|
||||||
$user['id'], $user['username'], $user['color'], $message, $created, dayKey(),
|
$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']);
|
trackEvent('message_text', '/api/messages.php', (int)$user['id']);
|
||||||
jsonResponse(['success' => true, 'message' => messagePayload([
|
jsonResponse(['success' => true, 'message' => messagePayload([
|
||||||
'id' => $db->lastInsertId(),
|
'id' => $messageId,
|
||||||
'user_id' => $user['id'],
|
'user_id' => $user['id'],
|
||||||
'username' => $user['username'],
|
'username' => $user['username'],
|
||||||
'color' => $user['color'],
|
'color' => $user['color'],
|
||||||
@@ -99,25 +102,28 @@ function sendVoiceMessage(): never {
|
|||||||
jsonResponse(['error' => 'Could not store voice clip'], 500);
|
jsonResponse(['error' => 'Could not store voice clip'], 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
$created = time();
|
|
||||||
$storedMime = $mime === 'video/webm' ? 'audio/webm' : $mime;
|
$storedMime = $mime === 'video/webm' ? 'audio/webm' : $mime;
|
||||||
$db = getDB();
|
$db = getDB();
|
||||||
try {
|
try {
|
||||||
$db->prepare("INSERT INTO messages
|
$db->prepare("INSERT INTO messages
|
||||||
(user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
(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]',
|
$user['id'], $user['username'], $user['color'], '[Voice clip]',
|
||||||
$filename, $storedMime, $duration, $created, dayKey(),
|
$filename, $storedMime, $duration, dayKey(),
|
||||||
]);
|
]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
deleteVoiceFile($filename);
|
deleteVoiceFile($filename);
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
$messageId = (int)$db->lastInsertId();
|
||||||
$db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?")
|
$db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?")
|
||||||
->execute([$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']);
|
trackEvent('message_voice', '/api/messages.php', (int)$user['id']);
|
||||||
jsonResponse(['success' => true, 'message' => messagePayload([
|
jsonResponse(['success' => true, 'message' => messagePayload([
|
||||||
'id' => $db->lastInsertId(),
|
'id' => $messageId,
|
||||||
'user_id' => $user['id'],
|
'user_id' => $user['id'],
|
||||||
'username' => $user['username'],
|
'username' => $user['username'],
|
||||||
'color' => $user['color'],
|
'color' => $user['color'],
|
||||||
@@ -159,7 +165,8 @@ function pollMessages(): never {
|
|||||||
$stmt = $db->prepare("SELECT * FROM messages
|
$stmt = $db->prepare("SELECT * FROM messages
|
||||||
WHERE day_key=? ORDER BY id DESC LIMIT ?");
|
WHERE day_key=? ORDER BY id DESC LIMIT ?");
|
||||||
$stmt->execute([dayKey(), $limit]);
|
$stmt->execute([dayKey(), $limit]);
|
||||||
$rows = array_reverse($stmt->fetchAll());
|
$rows = $stmt->fetchAll();
|
||||||
|
usort($rows, 'compareMessagesChronologically');
|
||||||
$hasMore = false;
|
$hasMore = false;
|
||||||
} else {
|
} else {
|
||||||
$stmt = $db->prepare("SELECT * FROM messages
|
$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 {
|
function archiveDays(): never {
|
||||||
$user = authRequired();
|
$user = authRequired();
|
||||||
$rows = personalArchiveRows($user, '', 1000);
|
$rows = personalArchiveRows($user, '', 1000);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
cursor: 0,
|
cursor: 0,
|
||||||
poll: {
|
poll: {
|
||||||
generation: 0, active: false, controller: null, wakeTimer: null, wakeResolve: null,
|
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,
|
loginChallenge: initialChallenge,
|
||||||
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
||||||
@@ -345,7 +345,7 @@
|
|||||||
const result = await api('messages.php', form({ action: 'send', message }))
|
const result = await api('messages.php', form({ action: 'send', message }))
|
||||||
.catch(() => ({ error: 'Message send failed. Polling will continue.' }));
|
.catch(() => ({ error: 'Message send failed. Polling will continue.' }));
|
||||||
if (result.error) { input.value = message; return toast(result.error); }
|
if (result.error) { input.value = message; return toast(result.error); }
|
||||||
mergeMessages([result.message], false);
|
acknowledgeSentMessage(result.message);
|
||||||
requestPoll();
|
requestPoll();
|
||||||
}
|
}
|
||||||
function startPoll() {
|
function startPoll() {
|
||||||
@@ -356,6 +356,7 @@
|
|||||||
state.poll.immediate = true;
|
state.poll.immediate = true;
|
||||||
state.poll.initial = true;
|
state.poll.initial = true;
|
||||||
state.poll.messages.clear();
|
state.poll.messages.clear();
|
||||||
|
state.poll.pendingThrough = 0;
|
||||||
void pollWorker(state.poll.generation);
|
void pollWorker(state.poll.generation);
|
||||||
}
|
}
|
||||||
function requestPoll() {
|
function requestPoll() {
|
||||||
@@ -376,6 +377,7 @@
|
|||||||
if (wake) wake();
|
if (wake) wake();
|
||||||
state.poll.immediate = false;
|
state.poll.immediate = false;
|
||||||
state.poll.messages.clear();
|
state.poll.messages.clear();
|
||||||
|
state.poll.pendingThrough = 0;
|
||||||
resetVoicePlaybackQueue();
|
resetVoicePlaybackQueue();
|
||||||
}
|
}
|
||||||
async function pollWorker(generation) {
|
async function pollWorker(generation) {
|
||||||
@@ -439,6 +441,7 @@
|
|||||||
state.poll.initial = false;
|
state.poll.initial = false;
|
||||||
const nextCursor = Math.max(requestedSince, merged.maxId);
|
const nextCursor = Math.max(requestedSince, merged.maxId);
|
||||||
state.cursor = Math.max(state.cursor, nextCursor);
|
state.cursor = Math.max(state.cursor, nextCursor);
|
||||||
|
if (state.cursor >= state.poll.pendingThrough) state.poll.pendingThrough = 0;
|
||||||
const recording = $('#recording');
|
const recording = $('#recording');
|
||||||
if (recording) {
|
if (recording) {
|
||||||
recording.hidden = !result.recording?.length;
|
recording.hidden = !result.recording?.length;
|
||||||
@@ -447,7 +450,12 @@
|
|||||||
+ (result.recording.length === 1 ? ' is' : ' are') + ' recording...'
|
+ (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) {
|
} catch (error) {
|
||||||
if (state.poll.active && state.poll.generation === generation) {
|
if (state.poll.active && state.poll.generation === generation) {
|
||||||
$('#cc-conn-dot').className = 'cc-conn-dot offline';
|
$('#cc-conn-dot').className = 'cc-conn-dot offline';
|
||||||
@@ -463,6 +471,12 @@
|
|||||||
if (!Number.isSafeInteger(id) || id < 1) return null;
|
if (!Number.isSafeInteger(id) || id < 1) return null;
|
||||||
return { ...message, id };
|
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) {
|
function isOwnMessage(message) {
|
||||||
const userId = Number(message?.user_id);
|
const userId = Number(message?.user_id);
|
||||||
return Number.isSafeInteger(userId) && userId > 0
|
return Number.isSafeInteger(userId) && userId > 0
|
||||||
@@ -476,6 +490,9 @@
|
|||||||
message.voice_duration, message.created_at
|
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) {
|
function mergeMessages(messages, incoming) {
|
||||||
const incomingIds = new Set();
|
const incomingIds = new Set();
|
||||||
let maxId = state.cursor;
|
let maxId = state.cursor;
|
||||||
@@ -495,7 +512,7 @@
|
|||||||
if (!list) return;
|
if (!list) return;
|
||||||
trimMessageStore();
|
trimMessageStore();
|
||||||
list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove();
|
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();
|
const nodes = new Map();
|
||||||
Array.from(list.querySelectorAll('.cc-msg')).forEach(item => {
|
Array.from(list.querySelectorAll('.cc-msg')).forEach(item => {
|
||||||
const id = Number(item.dataset.id);
|
const id = Number(item.dataset.id);
|
||||||
@@ -558,8 +575,9 @@
|
|||||||
}
|
}
|
||||||
function trimMessageStore() {
|
function trimMessageStore() {
|
||||||
const limit = Math.max(20, Number(state.config.chat?.max_rendered_messages) || 500);
|
const limit = Math.max(20, Number(state.config.chat?.max_rendered_messages) || 500);
|
||||||
const ids = [...state.poll.messages.keys()].sort((a, b) => a - b);
|
const messages = [...state.poll.messages.values()].sort(compareMessagesChronologically);
|
||||||
ids.slice(0, Math.max(0, ids.length - limit)).forEach(id => state.poll.messages.delete(id));
|
messages.slice(0, Math.max(0, messages.length - limit))
|
||||||
|
.forEach(message => state.poll.messages.delete(message.id));
|
||||||
}
|
}
|
||||||
function enqueueVoiceAutoplay(item) {
|
function enqueueVoiceAutoplay(item) {
|
||||||
if (!feature('auto_voice_play') || !state.voice.autoPlay) return;
|
if (!feature('auto_voice_play') || !state.voice.autoPlay) return;
|
||||||
@@ -690,7 +708,7 @@
|
|||||||
.catch(() => ({ error: 'Voice send failed. Polling will continue.' }));
|
.catch(() => ({ error: 'Voice send failed. Polling will continue.' }));
|
||||||
state.voice.sending = false;
|
state.voice.sending = false;
|
||||||
if (result.error) return toast(result.error);
|
if (result.error) return toast(result.error);
|
||||||
mergeMessages([result.message], false);
|
acknowledgeSentMessage(result.message);
|
||||||
discardVoice();
|
discardVoice();
|
||||||
requestPoll();
|
requestPoll();
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -94,7 +94,7 @@
|
|||||||
<span class="kw"><div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">></div></span>
|
<span class="kw"><div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">></div></span>
|
||||||
|
|
||||||
<span class="cm"><!-- 4. Script + init --></span>
|
<span class="cm"><!-- 4. Script + init --></span>
|
||||||
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.4"</span><span class="kw">></script></span>
|
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.5"</span><span class="kw">></script></span>
|
||||||
<span class="kw"><script></span>
|
<span class="kw"><script></span>
|
||||||
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
|
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
|
||||||
CyberChat.init(<span class="str">'#my-chat'</span>);
|
CyberChat.init(<span class="str">'#my-chat'</span>);
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
<script>
|
<script>
|
||||||
window.CYBERCHAT_BASE = '';
|
window.CYBERCHAT_BASE = '';
|
||||||
</script>
|
</script>
|
||||||
<script src="assets/js/cyberchat-app.js?v=20260609.4"></script>
|
<script src="assets/js/cyberchat-app.js?v=20260609.5"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
CyberChat.init('#chat-here');
|
CyberChat.init('#chat-here');
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
<script>
|
<script>
|
||||||
window.CYBERCHAT_BASE = '';
|
window.CYBERCHAT_BASE = '';
|
||||||
</script>
|
</script>
|
||||||
<script src="assets/js/cyberchat-app.js?v=20260609.4"></script>
|
<script src="assets/js/cyberchat-app.js?v=20260609.5"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
CyberChat.init('#app');
|
CyberChat.init('#app');
|
||||||
|
|||||||
Reference in New Issue
Block a user