From f51d8a5a872d85d4f67a6a1cbf9d7844e50b94fa Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 29 Mar 2026 21:37:08 +0100 Subject: [PATCH] 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> --- apps/ios/Shared/Views/Chat/ChatView.swift | 7 + .../simplex/common/views/chat/ChatView.kt | 12 +- ...26-03-29-initial-open-last-unread-block.md | 199 ++++++++++++++++++ src/Simplex/Chat/Store/Messages.hs | 44 +++- .../SQLite/Migrations/agent_query_plans.txt | 8 +- .../SQLite/Migrations/chat_query_plans.txt | 26 ++- 6 files changed, 280 insertions(+), 16 deletions(-) create mode 100644 plans/2026-03-29-initial-open-last-unread-block.md diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index cfdda87d66..fa1bce1233 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -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 { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index aa1707cc7f..5206d0556c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -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 diff --git a/plans/2026-03-29-initial-open-last-unread-block.md b/plans/2026-03-29-initial-open-last-unread-block.md new file mode 100644 index 0000000000..034b57118b --- /dev/null +++ b/plans/2026-03-29-initial-open-last-unread-block.md @@ -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 diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index f77c836b0d..c7ae7b16d3 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -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" diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index ebdf7e1f5c..1b881bd446 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -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=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index ac54043194..737d559007 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -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=?)