core: open chat on "new" unread items (contigous unread aread in the end) (#6724)

* core: open chat on "new" unread items (contigous unread aread in the end)

* move

* ui: match first unread logic with core

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-03-29 21:38:41 +01:00
committed by Evgeny Poberezkin
co-authored by Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
parent f0a3d163dd
commit f51d8a5a87
6 changed files with 280 additions and 16 deletions
@@ -351,6 +351,13 @@ struct ChatView: View {
if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] {
scrollView.scrollToItem(index)
} else if let viewedIdx = mergedItems.boxedValue.items.firstIndex(where: { !$0.hasUnread() }) {
// scroll to first unread after last viewed item (items reversed: 0 = newest)
if viewedIdx > 0 {
scrollView.scrollToItem(viewedIdx - 1)
} else {
scrollView.scrollToBottom()
}
} else if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) {
scrollView.scrollToItem(unreadIndex)
} else {
@@ -1753,7 +1753,17 @@ fun BoxScope.ChatItemsList(
val hoveredItemId = remember { mutableStateOf(null as Long?) }
val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, resetListState.value, saver = LazyListState.Saver) {
val openAroundItemId = chatModel.openAroundItemId.value
val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: mergedItems.value.items.indexOfLast { it.hasUnread() }
val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: run {
// scroll to first unread after last viewed item (items reversed: 0 = newest)
val viewedIdx = mergedItems.value.items.indexOfFirst { !it.hasUnread() }
if (viewedIdx > 0) {
viewedIdx - 1
} else if (viewedIdx < 0) {
mergedItems.value.items.indexOfLast { it.hasUnread() }
} else {
0 // viewed is bottom item, scroll to bottom
}
}
val reportsState = reportsListState
if (openAroundItemId != null) {
highlightedItems.value += openAroundItemId
@@ -0,0 +1,199 @@
# Initial chat open: jump to last unread block
## Problem
When opening a chat with unread messages, the app always scrolls to the oldest unread message (`minUnreadItemId`). For casual group members with hundreds of unreads, this forces them to scroll through the entire backlog to reach new messages.
The bottom circle scrolls to the latest messages without marking all as read (by design — moderators use this to reply quickly, then return to the top circle to read sequentially). But the next time the chat opens, it jumps back to the oldest unread.
Users want the initial open to skip old unreads and land on the "new" ones — messages that arrived after their last interaction.
## Design
Change `CPInitial` to use a different pivot for `getDirectChatAround'` / `getGroupChatAround'`.
Currently the pivot is `minUnreadItemId` (absolute first unread). Instead, try `maxViewedItemId` (last non-unread item in sort order) first. If not found, fall back to `minUnreadItemId`. The `getAround'` function is unchanged — it always loads `CRBefore`/`CRAfter` around the pivot and includes it.
**maxViewedItemId**: the last item in sort order that is not `CISRcvNew`.
- "Viewed" means received read or sent — any `item_status != CISRcvNew`.
### Why include works for both pivots
`getDirectChatAround'` always includes the pivot in the result. When the pivot is maxViewed (a read item), including it adds one extra read item — harmless. The client scrolls to the first unread in the loaded items, which is the first item in `afterCIs` (the new unreads after the gap). The include/exclude distinction is unnecessary.
### Sort order
- Groups: `(item_ts, chat_item_id)`
- Direct chats: `(created_at, chat_item_id)`
### Cases
Items in display order (left = oldest/top, right = newest/bottom). U = unread (`CISRcvNew`), R = not unread (read, sent, event).
**Case 1: Unreads contiguous from bottom, no gap**
```
R...R U...U
↑ maxViewed (last R) used as pivot
```
`afterCIs` = the unreads. Same as current behavior.
**Case 2: Gap, then new unreads at bottom**
```
R...R U...U R...R U...U
↑ maxViewed used as pivot
```
`afterCIs` = new unreads only. Skips old unreads. This is the desired improvement.
**Case 3: Gap at bottom, no new unreads**
```
R...R U...U R...R
↑ maxViewed used as pivot
```
`afterCIs` = empty. Items loaded are the latest. Old unreads reachable via top circle.
**Case 4: All unread**
```
U...U
```
maxViewed = NULL. Fall back to `minUnreadItemId` as pivot. Current behavior.
**Case 5: No unreads**
`maxViewedItemId` returns some item but `minUnreadItemId` returns `Nothing` — no unreads exist. Handled by stats showing zero unreads. `getAround'` loads items around maxViewed, which are the latest items.
Actually: maxViewed is always found when items exist (every chat has at least sent items or read items unless case 4). So the flow is: maxViewed found → load around it → stats show 0 unreads → client shows latest items.
### No UI changes needed
Only `CPInitial` backend logic changes. The top circle, unread counter, unread separator, and all pagination continue to use `minUnreadItemId` as before.
### Out-of-order delivery
A late-arriving group message with old `item_ts` but recent `created_at` sorts into the old unread block in display order and gets skipped on initial open. This is acceptable — the top circle still reaches it.
### Notes (local chat)
Notes (`getLocalChatInitial_`) have unread handling in code but it's dead — all items are sent, `CISRcvNew` never occurs. No change needed.
### Open concern
In case 2, `CRBefore(maxViewed)` loads items before the gap, which may include old unreads. The client's scroll logic finds the first unread in loaded items (`lastIndex(where: hasUnread)` in reversed list), which could be an old unread from `beforeCIs` rather than a new unread from `afterCIs`. To be validated during testing — if problematic, may need client-side adjustment or limiting `beforeCIs` count.
## Implementation
### Files to modify
`src/Simplex/Chat/Store/Messages.hs` — all changes are here.
### New functions
#### Direct chats
```haskell
-- max viewed item: received read or sent (any item_status != CISRcvNew)
getContactMaxViewedItemId_ :: DB.Connection -> User -> Contact -> IO (Maybe ChatItemId)
```
Query:
```sql
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_status != ?
ORDER BY created_at DESC, chat_item_id DESC
LIMIT 1
```
#### Groups
```haskell
-- max viewed item: received read or sent (any item_status != CISRcvNew)
getGroupMaxViewedItemId_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Maybe ChatItemId)
```
Mirrors `queryUnreadGroupItems` structure but with `item_status != ?` instead of `item_status = ?`. Handles the same 4-case scope/content filter dispatch. New function `queryViewedGroupItems`.
Query (for the no-scope, no-content-filter case):
```sql
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND group_id = ?
AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL
AND item_status != ?
ORDER BY item_ts DESC, chat_item_id DESC
LIMIT 1
```
### Modified functions
#### `getDirectChatInitial_`
Current:
```haskell
getDirectChatInitial_ db user ct contentFilter count = do
liftIO (getContactMinUnreadId_ db user ct) >>= \case
Just minUnreadItemId -> do
unreadCount <- liftIO $ getContactUnreadCount_ db user ct
let stats = emptyChatStats {unreadCount, minUnreadItemId}
getDirectChatAround' db user ct contentFilter minUnreadItemId count "" stats
Nothing -> (,Just $ NavigationInfo 0 0) <$> getDirectChatLast_ db user ct contentFilter count ""
```
New — only the pivot source changes, rest stays the same:
```haskell
getDirectChatInitial_ db user ct contentFilter count = do
liftIO (getContactMaxViewedItemId_ db user ct >>= maybe (getContactMinUnreadId_ db user ct) (pure . Just)) >>= \case
Just pivotId -> do
unreadCount <- liftIO $ getContactUnreadCount_ db user ct
minUnreadItemId <- fromMaybe 0 <$> liftIO (getContactMinUnreadId_ db user ct)
let stats = emptyChatStats {unreadCount, minUnreadItemId}
getDirectChatAround' db user ct contentFilter pivotId count "" stats
Nothing -> (,Just $ NavigationInfo 0 0) <$> getDirectChatLast_ db user ct contentFilter count ""
```
#### `getGroupChatInitial_`
Same minimal change — only the pivot source:
```haskell
getGroupChatInitial_ db user g scopeInfo_ contentFilter count = do
(getGroupMaxViewedItemId_ db user g scopeInfo_ contentFilter >>= maybe (getGroupMinUnreadId_ db user g scopeInfo_ contentFilter) (pure . Just)) >>= \case
Just pivotId -> do
stats <- getGroupStats_ db user g scopeInfo_
getGroupChatAround' db user g scopeInfo_ contentFilter pivotId count "" stats
Nothing -> do
stats <- liftIO $ getStats 0 (0, 0)
(,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g scopeInfo_ contentFilter count "" stats
where
getStats minUnreadItemId (unreadCount, unreadMentions) = do
reportsCount <- getGroupReportsCount_ db user g False
pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False}
```
### Summary of all affected functions
| Function | Change |
|----------|--------|
| `getDirectChatInitial_` | Try maxViewed first, fall back to minUnread, same `getAround'` call |
| `getGroupChatInitial_` | Same |
| **New:** `getContactMaxViewedItemId_` | MAX non-unread by (created_at DESC, id DESC) |
| **New:** `getGroupMaxViewedItemId_` | MAX non-unread with scope/filter dispatch |
| **New:** `queryViewedGroupItems` | Like `queryUnreadGroupItems` but `item_status != ?` |
Nothing else changes. `getDirectChatAround'`, `getGroupChatAround'`, `getDirectChatAround_`, `getGroupChatAround_`, `getContactMinUnreadId_`, `getGroupMinUnreadId_`, `getContactStats_`, `getGroupStats_`, `getChatItemIDs`, `NavigationInfo` computation, mark-read operations, UI code — all unchanged.
### Performance
- `maxViewedItemId` query: scans backward from the largest sort key, skipping unread items. Fast when there are recent read/sent items (the common case). Worst case: all items are unread — returns `Nothing` and falls back to minUnread.
- All other queries are existing code, same performance.
- Queries run only during `CPInitial` (chat open). No writes.
### Testing
1. Open chat with unreads, no prior interaction → same as current (case 1/4)
2. Open chat, jump to bottom (marks bottom screen read), close, reopen → lands on new unreads after the gap (case 2)
3. Open chat, jump to bottom, reply, close, new messages arrive, reopen → lands on new messages (case 2)
4. Open chat, jump to bottom, close, no new messages, reopen → loads around last viewed item at bottom (case 3)
5. Open chat, read from top (marks first screen read), close, reopen → lands on next unread after first screen, same as current (case 1)
6. Group with scope (member support chat) → same behavior with scope filter applied
7. Group with content filter (reports) → same behavior with content filter applied
+35 -9
View File
@@ -1303,7 +1303,8 @@ getDirectChatInitial_ db user ct contentFilter count = do
Just minUnreadItemId -> do
unreadCount <- liftIO $ getContactUnreadCount_ db user ct
let stats = emptyChatStats {unreadCount, minUnreadItemId}
getDirectChatAround' db user ct contentFilter minUnreadItemId count "" stats
pivotId <- liftIO $ fromMaybe minUnreadItemId <$> getContactMaxViewedItemId_ db user ct
getDirectChatAround' db user ct contentFilter pivotId count "" stats
Nothing -> (,Just $ NavigationInfo 0 0) <$> getDirectChatLast_ db user ct contentFilter count ""
getContactStats_ :: DB.Connection -> User -> Contact -> IO ChatStats
@@ -1326,6 +1327,21 @@ getContactMinUnreadId_ db User {userId} Contact {contactId} =
|]
(userId, contactId, CISRcvNew)
-- max viewed item: received read or sent (any item_status != CISRcvNew)
getContactMaxViewedItemId_ :: DB.Connection -> User -> Contact -> IO (Maybe ChatItemId)
getContactMaxViewedItemId_ db User {userId} Contact {contactId} =
fmap join . maybeFirstRow fromOnly $
DB.query
db
[sql|
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_status != ?
ORDER BY created_at DESC, chat_item_id DESC
LIMIT 1
|]
(userId, contactId, CISRcvNew)
getContactUnreadCount_ :: DB.Connection -> User -> Contact -> IO Int
getContactUnreadCount_ db User {userId} Contact {contactId} =
fromOnly . head
@@ -1633,7 +1649,8 @@ getGroupChatInitial_ db user g scopeInfo_ contentFilter count = do
Just minUnreadItemId -> do
unreadCounts <- getGroupUnreadCount_ db user g scopeInfo_ Nothing
stats <- liftIO $ getStats minUnreadItemId unreadCounts
getGroupChatAround' db user g scopeInfo_ contentFilter minUnreadItemId count "" stats
pivotId <- fromMaybe minUnreadItemId <$> getGroupMaxViewedItemId_ db user g scopeInfo_ contentFilter
getGroupChatAround' db user g scopeInfo_ contentFilter pivotId count "" stats
Nothing -> do
stats <- liftIO $ getStats 0 (0, 0)
(,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g scopeInfo_ contentFilter count "" stats
@@ -1652,14 +1669,23 @@ getGroupStats_ db user g scopeInfo_ = do
getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Maybe ChatItemId)
getGroupMinUnreadId_ db user g scopeInfo_ contentFilter =
fmap join . maybeFirstRow fromOnly $
queryUnreadGroupItems db user g scopeInfo_ contentFilter baseQuery orderLimit
queryUnreadGroupItems db user g scopeInfo_ contentFilter " item_status = ? " baseQuery orderLimit
where
baseQuery = "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? "
orderLimit = " ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1"
-- max viewed item: received read or sent (any item_status != CISRcvNew)
getGroupMaxViewedItemId_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Maybe ChatItemId)
getGroupMaxViewedItemId_ db user g scopeInfo_ contentFilter =
fmap join . maybeFirstRow fromOnly $
queryUnreadGroupItems db user g scopeInfo_ contentFilter " item_status != ? " baseQuery orderLimit
where
baseQuery = "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? "
orderLimit = " ORDER BY item_ts DESC, chat_item_id DESC LIMIT 1"
getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Int, Int)
getGroupUnreadCount_ db user g scopeInfo_ contentFilter =
head <$> queryUnreadGroupItems db user g scopeInfo_ contentFilter baseQuery ""
head <$> queryUnreadGroupItems db user g scopeInfo_ contentFilter " item_status = ? " baseQuery ""
where
baseQuery = "SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? "
@@ -1671,26 +1697,26 @@ getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived =
"SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0"
(userId, groupId, MCReport_, BI archived)
queryUnreadGroupItems :: FromRow r => DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Query -> Query -> ExceptT StoreError IO [r]
queryUnreadGroupItems db User {userId} GroupInfo {groupId} scopeInfo_ contentFilter baseQuery orderLimit =
queryUnreadGroupItems :: FromRow r => DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Query -> Query -> Query -> ExceptT StoreError IO [r]
queryUnreadGroupItems db User {userId} GroupInfo {groupId} scopeInfo_ contentFilter statusCond baseQuery orderLimit =
case (scopeInfo_, contentFilter) of
(Nothing, Nothing) ->
liftIO $
DB.query
db
(baseQuery <> " AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? " <> orderLimit)
(baseQuery <> " AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND " <> statusCond <> orderLimit)
(userId, groupId, CISRcvNew)
(Nothing, Just mcTag) ->
liftIO $
DB.query
db
(baseQuery <> " AND msg_content_tag = ? AND item_status = ? " <> orderLimit)
(baseQuery <> " AND msg_content_tag = ? AND " <> statusCond <> orderLimit)
(userId, groupId, mcTag, CISRcvNew)
(Just GCSIMemberSupport {groupMember_ = m}, Nothing) ->
liftIO $
DB.query
db
(baseQuery <> " AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? AND item_status = ? " <> orderLimit)
(baseQuery <> " AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? AND " <> statusCond <> orderLimit)
(userId, groupId, GCSTMemberSupport_, groupMemberId' <$> m, CISRcvNew)
(Just _scope, Just _mcTag) ->
throwError $ SEInternalError "group scope and content filter are not supported together"
@@ -1197,10 +1197,6 @@ Query: UPDATE connections SET smp_agent_version = ? WHERE conn_id = ?
Plan:
SEARCH connections USING PRIMARY KEY (conn_id=?)
Query: UPDATE deleted_snd_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE deleted_snd_chunk_replica_id = ?
Plan:
SEARCH deleted_snd_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE messages SET msg_body = x'' WHERE conn_id = ? AND internal_id = ?
Plan:
SEARCH messages USING PRIMARY KEY (conn_id=? AND internal_id=?)
@@ -1209,6 +1205,10 @@ Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ?
Plan:
SEARCH ratchets USING PRIMARY KEY (conn_id=?)
Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ?
Plan:
SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ?
Plan:
SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?)
@@ -3133,6 +3133,16 @@ Query:
Plan:
SEARCH chat_items USING INDEX idx_chat_items_contact_id (contact_id=?)
Query:
SELECT chat_item_id
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_status != ?
ORDER BY created_at DESC, chat_item_id DESC
LIMIT 1
Plan:
SEARCH chat_items USING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?)
Query:
SELECT chat_item_id
FROM chat_items
@@ -4733,6 +4743,14 @@ Query:
Plan:
SEARCH protocol_servers USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE rcv_files
SET to_receive = 1, user_approved_relays = ?, updated_at = ?
WHERE file_id = ?
Plan:
SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE remote_controllers
SET ctrl_device_name = ?, dh_priv_key = ?, prev_dh_priv_key = dh_priv_key
@@ -6043,7 +6061,7 @@ Query: SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0
Plan:
SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?)
Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ?
Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ?
Plan:
SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?)
@@ -6085,7 +6103,11 @@ Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ?
Plan:
SEARCH chat_items USING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?)
Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1
Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status != ? ORDER BY item_ts DESC, chat_item_id DESC LIMIT 1
Plan:
SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=?)
Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1
Plan:
SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?)