Skip to content

Commit caa2930

Browse files
JSKittyclaude
andcommitted
fix: stuck "New" divider — dedupe + tighten read-state policy
- message_new / mls_message_new handlers double-pushed into chat.messages when it aliased eventCache's entry.events after openChat. The duplicate sat at the tail; on reopen, findIndex(lastReadOnOpen) returned the first copy and the loop then saw the second copy as "another unread" — the divider anchored above a message the user had already read. - Expose eventCache.getEventsRef() so the message handlers can detect the shared-array case and skip the redundant insertion. - Tighten the read-state policy across all paths: - Open chat → mark synchronously (OS badge clears immediately), with last_read snapshotted first so the divider can still anchor above the first missed message. - Receive while pinned → mark + clear divider. - Receive while scrolled up → don't mark, divider stays. - Scroll back to pin → mark (badge clears), divider stays. - Close while pinned → mark. - Close while scrolled up → don't mark; honestly preserves unread state. - Remove the focus-gated markAsRead inside updateChat — the IPC could hang/fail and leave last_read stuck. Direct site-level marks above cover every case more reliably. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0e56326 commit caa2930

2 files changed

Lines changed: 138 additions & 109 deletions

File tree

src/js/event-cache.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,15 @@ class EventCache {
393393
return entry.events.find(e => e.id === eventId) || null;
394394
}
395395

396+
/**
397+
* Get the underlying events array reference for a conversation. Used by
398+
* callers that want to detect whether their `chat.messages` is the same
399+
* array (post-openChat aliases the two; addEvent then mutates both).
400+
*/
401+
getEventsRef(conversationId) {
402+
return this.cache.get(conversationId)?.events ?? null;
403+
}
404+
396405
/**
397406
* Update the total event count for a conversation (e.g., after sync)
398407
* @param {string} conversationId - The conversation identifier

src/main.js

Lines changed: 129 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -2161,48 +2161,55 @@ async function setupRustListeners() {
21612161
// During sync, only add if this chat is currently open (to avoid cache flooding)
21622162
// After sync complete, always add to cache
21632163
const shouldAddToCache = fSyncComplete || group_id === strOpenChat;
2164+
let cacheInsertedIntoChatMessages = false;
21642165
if (shouldAddToCache) {
21652166
const added = eventCache.addEvent(group_id, message);
21662167
if (!added) return;
2168+
// openChat aliases chat.messages = entry.events, so addEvent has
2169+
// already inserted into chat.messages. Skip the manual insertion
2170+
// below to avoid duplicates.
2171+
cacheInsertedIntoChatMessages = chat.messages === eventCache.getEventsRef(group_id);
21672172
}
2168-
2173+
21692174
// Clear typing indicator for the sender when they send a message
21702175
if (!message.mine && chat.active_typers) {
21712176
// For group chats, use npub if available; for DMs, use sender identifier
21722177
chat.active_typers = chat.active_typers.filter(npub => npub !== message.npub);
21732178
}
2174-
2175-
// Find the correct position to insert the message based on timestamp (efficient binary search)
2176-
const messages = chat.messages;
2177-
2178-
// Check if the array is empty or the new message is newer than the newest message
2179-
if (messages.length === 0 || message.at > messages[messages.length - 1].at) {
2180-
// Insert at the end (newest)
2181-
messages.push(message);
2182-
}
2183-
// Check if the new message is older than the oldest message
2184-
else if (message.at < messages[0].at) {
2185-
// Insert at the beginning (oldest)
2186-
messages.unshift(message);
2187-
}
2188-
// Otherwise, find the correct position in the middle using binary search
2189-
else {
2190-
// Binary search for better performance with large message arrays
2191-
let low = 0;
2192-
let high = messages.length - 1;
2193-
2194-
while (low <= high) {
2195-
const mid = Math.floor((low + high) / 2);
2196-
2197-
if (messages[mid].at < message.at) {
2198-
low = mid + 1;
2199-
} else {
2200-
high = mid - 1;
2179+
2180+
if (!cacheInsertedIntoChatMessages) {
2181+
// Find the correct position to insert the message based on timestamp (efficient binary search)
2182+
const messages = chat.messages;
2183+
2184+
// Check if the array is empty or the new message is newer than the newest message
2185+
if (messages.length === 0 || message.at > messages[messages.length - 1].at) {
2186+
// Insert at the end (newest)
2187+
messages.push(message);
2188+
}
2189+
// Check if the new message is older than the oldest message
2190+
else if (message.at < messages[0].at) {
2191+
// Insert at the beginning (oldest)
2192+
messages.unshift(message);
2193+
}
2194+
// Otherwise, find the correct position in the middle using binary search
2195+
else {
2196+
// Binary search for better performance with large message arrays
2197+
let low = 0;
2198+
let high = messages.length - 1;
2199+
2200+
while (low <= high) {
2201+
const mid = Math.floor((low + high) / 2);
2202+
2203+
if (messages[mid].at < message.at) {
2204+
low = mid + 1;
2205+
} else {
2206+
high = mid - 1;
2207+
}
22012208
}
2209+
2210+
// Insert the message at the correct position (low is now the index where it should go)
2211+
messages.splice(low, 0, message);
22022212
}
2203-
2204-
// Insert the message at the correct position (low is now the index where it should go)
2205-
messages.splice(low, 0, message);
22062213
}
22072214

22082215
// If this group has the open chat, update it
@@ -2211,7 +2218,13 @@ async function setupRustListeners() {
22112218
// Increment rendered count since we're adding a new message
22122219
proceduralScrollState.renderedMessageCount++;
22132220
proceduralScrollState.totalMessageCount++;
2214-
// Mark on own-send: updateChat's auto-mark is focus-gated.
2221+
// Open chat + pinned = user saw it land. Mark and drop the
2222+
// divider so it tracks unread state.
2223+
if (!message.mine && chatPinnedToBottom) {
2224+
markAsRead(chat, message);
2225+
clearUnreadDivider();
2226+
}
2227+
// Own-send catches up to the latest non-mine message.
22152228
if (message.mine) {
22162229
let lastContactMsg = null;
22172230
for (let i = chat.messages.length - 1; i >= 0; i--) {
@@ -2841,9 +2854,14 @@ async function setupRustListeners() {
28412854
// During sync, only add if this chat is currently open (to avoid cache flooding)
28422855
// After sync complete, always add to cache
28432856
const shouldAddToCache = fSyncComplete || chat.id === strOpenChat;
2857+
let cacheInsertedIntoChatMessages = false;
28442858
if (shouldAddToCache) {
28452859
const added = eventCache.addEvent(chat.id, newMessage);
28462860
if (!added) return;
2861+
// openChat assigns chat.messages = entry.events, so the two often
2862+
// share an array reference. addEvent already inserted into that
2863+
// shared array — a second manual insertion below would duplicate.
2864+
cacheInsertedIntoChatMessages = chat.messages === eventCache.getEventsRef(chat.id);
28472865
}
28482866

28492867
// Clear typing indicator for the sender when they send a message
@@ -2852,40 +2870,45 @@ async function setupRustListeners() {
28522870
chat.active_typers = chat.active_typers.filter(npub => npub !== chat.id);
28532871
}
28542872

2855-
// Find the correct position to insert the message based on timestamp
2856-
const messages = chat.messages;
2873+
if (!cacheInsertedIntoChatMessages) {
2874+
// Find the correct position to insert the message based on timestamp
2875+
const messages = chat.messages;
28572876

2858-
// Check if the array is empty or the new message is newer than (or equal to) the newest message
2859-
if (messages.length === 0 || newMessage.at >= messages[messages.length - 1].at) {
2860-
// Insert at the end (newest)
2861-
messages.push(newMessage);
2862-
2863-
// Sort chats by most recent activity (message or metadata fallback)
2864-
arrChats.sort((a, b) => getChatSortTimestamp(b) - getChatSortTimestamp(a));
2865-
}
2866-
// Check if the new message is older than the oldest message
2867-
else if (newMessage.at < messages[0].at) {
2868-
// Insert at the beginning (oldest)
2869-
messages.unshift(newMessage);
2870-
}
2871-
// Otherwise, find the correct position in the middle
2872-
else {
2873-
// Binary search for better performance with large message arrays
2874-
let low = 0;
2875-
let high = messages.length - 1;
2877+
// Check if the array is empty or the new message is newer than (or equal to) the newest message
2878+
if (messages.length === 0 || newMessage.at >= messages[messages.length - 1].at) {
2879+
// Insert at the end (newest)
2880+
messages.push(newMessage);
2881+
}
2882+
// Check if the new message is older than the oldest message
2883+
else if (newMessage.at < messages[0].at) {
2884+
// Insert at the beginning (oldest)
2885+
messages.unshift(newMessage);
2886+
}
2887+
// Otherwise, find the correct position in the middle
2888+
else {
2889+
// Binary search for better performance with large message arrays
2890+
let low = 0;
2891+
let high = messages.length - 1;
28762892

2877-
while (low <= high) {
2878-
const mid = Math.floor((low + high) / 2);
2893+
while (low <= high) {
2894+
const mid = Math.floor((low + high) / 2);
28792895

2880-
if (messages[mid].at < newMessage.at) {
2881-
low = mid + 1;
2882-
} else {
2883-
high = mid - 1;
2896+
if (messages[mid].at < newMessage.at) {
2897+
low = mid + 1;
2898+
} else {
2899+
high = mid - 1;
2900+
}
28842901
}
2902+
2903+
// Insert the message at the correct position (low is now the index where it should go)
2904+
messages.splice(low, 0, newMessage);
28852905
}
2906+
}
28862907

2887-
// Insert the message at the correct position (low is now the index where it should go)
2888-
messages.splice(low, 0, newMessage);
2908+
// Newest-first chat list sort (independent of how the message landed
2909+
// in chat.messages).
2910+
if (newMessage.at >= (chat.messages[chat.messages.length - 1]?.at ?? 0)) {
2911+
arrChats.sort((a, b) => getChatSortTimestamp(b) - getChatSortTimestamp(a));
28892912
}
28902913

28912914
// If this user has the open chat, then update the chat too
@@ -2894,9 +2917,14 @@ async function setupRustListeners() {
28942917
// Increment rendered count since we're adding a new message
28952918
proceduralScrollState.renderedMessageCount++;
28962919
proceduralScrollState.totalMessageCount++;
2897-
// Mark on own-send: updateChat's auto-mark is focus-gated and
2898-
// won't fire for messages that arrived while Vector was
2899-
// backgrounded.
2920+
// Open chat + pinned = user saw it land. Mark and drop the
2921+
// divider: receiving the message in real-time is the same
2922+
// "caught up" signal as closing and reopening.
2923+
if (!newMessage.mine && chatPinnedToBottom) {
2924+
markAsRead(chat, newMessage);
2925+
clearUnreadDivider();
2926+
}
2927+
// Own-send catches up to the latest non-mine message.
29002928
if (newMessage.mine) {
29012929
let lastContactMsg = null;
29022930
for (let i = chat.messages.length - 1; i >= 0; i--) {
@@ -5256,29 +5284,10 @@ async function updateChat(chat, arrMessages = [], profile = null, fClicked = fal
52565284

52575285
if (chat?.messages.length || arrMessages.length) {
52585286

5259-
// Auto-mark messages as read when chat is opened AND window is focused.
5260-
// Resolved without awaiting so the message render is not blocked by an
5261-
// IPC roundtrip — the same race that used to swallow the first
5262-
// attachment_upload_progress events.
5263-
if (chat?.messages?.length) {
5264-
const focusPromise = (platformFeatures.os !== 'android' && platformFeatures.os !== 'ios')
5265-
? getCurrentWindow().isFocused()
5266-
: Promise.resolve(true);
5267-
focusPromise.then(isWindowFocused => {
5268-
if (!isWindowFocused) return;
5269-
// Find the latest message from the other person (not from current user)
5270-
let lastContactMsg = null;
5271-
for (let i = chat.messages.length - 1; i >= 0; i--) {
5272-
if (!chat.messages[i].mine) {
5273-
lastContactMsg = chat.messages[i];
5274-
break;
5275-
}
5276-
}
5277-
if (lastContactMsg && chat.last_read !== lastContactMsg.id) {
5278-
markAsRead(chat, lastContactMsg);
5279-
}
5280-
});
5281-
}
5287+
// markAsRead is handled by callers (openChat synchronously, message_new
5288+
// handlers for real-time arrivals, closeChat on exit, onFocusChanged on
5289+
// window refocus). An async focus-gated markAsRead used to live here,
5290+
// but the IPC could hang/fail and leave chat.last_read stuck behind.
52825291

52835292
if (!arrMessages.length) return;
52845293

@@ -6305,10 +6314,17 @@ async function openChat(contact) {
63056314
const isGroup = chat?.chat_type === 'MlsGroup';
63066315
const profile = !isGroup ? getProfile(contact) : null;
63076316
strOpenChat = contact;
6308-
// Snapshot last_read BEFORE any updateChat callupdateChat queues
6309-
// an async markAsRead that races ahead and would erase the boundary
6310-
// we need to find the first unread message.
6317+
// Snapshot last_read BEFORE the open-time markAsReadthe divider needs
6318+
// the stale value to find the boundary, but we still want to advance
6319+
// chat.last_read so the OS badge clears immediately on entering the chat.
63116320
const lastReadOnOpen = chat?.last_read || '';
6321+
if (chat?.messages?.length) {
6322+
let latestNonMine = null;
6323+
for (let i = chat.messages.length - 1; i >= 0; i--) {
6324+
if (!chat.messages[i].mine) { latestNonMine = chat.messages[i]; break; }
6325+
}
6326+
if (latestNonMine) markAsRead(chat, latestNonMine);
6327+
}
63126328

63136329
// Render the header SYNCHRONOUSLY using whatever in-memory data we have,
63146330
// so the user sees the contact name + avatar the instant the chat panel
@@ -6459,20 +6475,9 @@ async function openChat(contact) {
64596475
domChatMessageInputEmoji.style.display = '';
64606476
}
64616477

6462-
// Mark as read on open (needed for Windows where is_focused may not work)
6463-
// Only mark the last contact message, not our own messages
6464-
if (initialMessages?.length) {
6465-
let lastContactMsg = null;
6466-
for (let i = initialMessages.length - 1; i >= 0; i--) {
6467-
if (!initialMessages[i].mine) {
6468-
lastContactMsg = initialMessages[i];
6469-
break;
6470-
}
6471-
}
6472-
if (lastContactMsg) {
6473-
markAsRead(chat, lastContactMsg);
6474-
}
6475-
}
6478+
// last_read is not advanced on open — the divider needs the stale value
6479+
// to anchor above the first missed message. closeChat / msg-while-pinned
6480+
// / onFocusChanged are the catch-up signals.
64766481

64776482
// Update the back button notification dot
64786483
updateChatBackNotification();
@@ -6535,11 +6540,12 @@ async function closeChat() {
65356540
domChild.remove();
65366541
}
65376542

6538-
// If the chat had any messages, mark the last contact message as read before leaving
6539-
if (strOpenChat) {
6543+
// Only catch up last_read on close when the user is actually at the
6544+
// bottom. Marking on close while scrolled up would lie about messages the
6545+
// user never scrolled down to see — the OS badge has to stay accurate.
6546+
if (strOpenChat && chatPinnedToBottom) {
65406547
const closedChat = arrChats.find(c => c.id === strOpenChat);
65416548
if (closedChat?.messages?.length) {
6542-
// Find the last non-mine message (same logic as updateChat)
65436549
let lastContactMsg = null;
65446550
for (let i = closedChat.messages.length - 1; i >= 0; i--) {
65456551
if (!closedChat.messages[i].mine) {
@@ -6553,6 +6559,9 @@ async function closeChat() {
65536559
}
65546560
}
65556561

6562+
// Drop the divider ref so the next openChat starts from a clean slate.
6563+
clearUnreadDivider();
6564+
65566565
// Trim the event cache for this chat to free memory
65576566
// (keeps max 100 events, removes older ones loaded during scroll)
65586567
if (strOpenChat) {
@@ -9854,9 +9863,20 @@ function handleChatScrollIntent() {
98549863
const pxFromBottom = domChatMessages.scrollHeight - domChatMessages.scrollTop - domChatMessages.clientHeight;
98559864
const wasPinned = chatPinnedToBottom;
98569865
chatPinnedToBottom = pxFromBottom < PIN_THRESHOLD_PX;
9857-
// User scrolled themselves back into pin range — clear the unread badge
9858-
// since they're effectively caught up.
9859-
if (!wasPinned && chatPinnedToBottom) clearUnreadBelow();
9866+
// User scrolled themselves back into pin range — clear the badge and
9867+
// advance last_read so the OS unread indicator reflects reality. The
9868+
// divider stays put until the chat is closed.
9869+
if (!wasPinned && chatPinnedToBottom) {
9870+
clearUnreadBelow();
9871+
const currentChat = getChat(strOpenChat);
9872+
if (currentChat?.messages?.length) {
9873+
let latestNonMine = null;
9874+
for (let i = currentChat.messages.length - 1; i >= 0; i--) {
9875+
if (!currentChat.messages[i].mine) { latestNonMine = currentChat.messages[i]; break; }
9876+
}
9877+
if (latestNonMine) markAsRead(currentChat, latestNonMine);
9878+
}
9879+
}
98609880
}
98619881

98629882
function softChatScroll() {

0 commit comments

Comments
 (0)