@@ -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 call — updateChat 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 markAsRead — the 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
98629882function softChatScroll ( ) {
0 commit comments