From 222fc4ad996507945146d614b06f13b66af328cf Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:33:18 +0000 Subject: [PATCH] android, desktop: open group member profile without loading all members (#7388) * android, desktop: open group member profile without loading all members Clicking member avatar in chat loaded the whole member list (apiListMembers) before showing member profile, and it was repeated on every click - in a group with 10000 members it takes several seconds. The full list is not needed to show the profile of one member, so instead the opened member is added to the model, the same way as in iOS app. * plans: member profile in large groups * plans: correct relay warning section - it is not affected by the change --- .../simplex/common/views/chat/ChatView.kt | 13 +- ...-18-member-profile-open-in-large-groups.md | 150 ++++++++++++++++++ 2 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 plans/2026-08-18-member-profile-open-in-large-groups.md 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 12f13a426f..9bbfda558f 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 @@ -502,14 +502,17 @@ fun ChatView( groupMembersJob = scope.launch(Dispatchers.Default) { val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) val stats = r?.second - val (_, code) = if (member.memberActive) { + val (updatedMember, code) = if (member.memberActive) { val memCode = chatModel.controller.apiGetGroupMemberCode(chatRh, groupInfo.apiId, member.groupMemberId) - member to memCode?.second + (memCode?.first ?: r?.first ?: member) to memCode?.second } else { - member to null + (r?.first ?: member) to null + } + if (!isActive || chatModel.chatId.value != groupInfo.id) return@launch + // members are not loaded in large groups, so only the opened member is added to the model + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(chatRh, groupInfo, updatedMember) } - setGroupMembers(chatRh, groupInfo, chatModel) - if (!isActive) return@launch if (chatsCtx.secondaryContextFilter == null) { ModalManager.end.closeModals() diff --git a/plans/2026-08-18-member-profile-open-in-large-groups.md b/plans/2026-08-18-member-profile-open-in-large-groups.md new file mode 100644 index 0000000000..2e5ccc3bbd --- /dev/null +++ b/plans/2026-08-18-member-profile-open-in-large-groups.md @@ -0,0 +1,150 @@ +# Open Member Profile Without Loading All Group Members + +## Context + +Tapping a member's avatar in chat history takes several seconds in a group with +10000 members, on every tap, on Android and desktop. + +**Root cause**: `showMemberInfo` (ChatView.kt:499) awaits three API calls before +showing the modal: + +1. `apiGroupMemberInfo` — single member, O(1) in group size +2. `apiGetGroupMemberCode` — single member, O(1) +3. `setGroupMembers` (ChatListNavLinkView.kt:254) — `apiListMembers`, the **whole + member list** + +Call 3 is the cost. `APIListMembers` (Commands.hs:3212) runs `getGroup`, which +loads every member with its profile (Groups.hs:938, 1222), the profile includes +the avatar (`p.image` in `groupMemberQuery`, Shared.hs:762), and the result is +encoded to JSON, passed across the FFI boundary and decoded into 10000 +`GroupMember` objects by the client. The codebase already annotates this call as +"very heavy query in large groups" (SimpleXAPI.kt:846). + +There is no `membersLoaded` guard on this call, unlike `GroupMentions.kt:116`, so +the full list is re-loaded on *every* tap even when it is already in the model. + +Measured on a 10009-member group (real member rows, half with a 11.7 KB avatar): +the SQL itself takes 0.21 s and returns ~64 MB of column data (5.1 MB without +avatars). The seconds are the JSON encode/transfer/decode of that payload. + +The full member list is not needed to show one member's profile. It is loaded +only so that `chatModel.getGroupMember` (ChatModel.kt:357) resolves the member +for the modal (ChatView.kt:521) and for the "Verify security code" screen +(GroupMemberInfoView.kt:209). + +## Solution Summary + +Do not load the member list on this path. Add the opened member to the model +instead, and show the modal — this is what the iOS app already does +(ChatView.swift:2051-2058, since 03bc4e5d0, "ios: display reactions in groups by +member"). The Kotlin path was never updated to match. + +```kotlin +val (updatedMember, code) = if (member.memberActive) { + val memCode = chatModel.controller.apiGetGroupMemberCode(...) + (memCode?.first ?: r?.first ?: member) to memCode?.second +} else { + (r?.first ?: member) to null +} +if (!isActive || chatModel.chatId.value != groupInfo.id) return@launch +withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(chatRh, groupInfo, updatedMember) +} +``` + +After the change the tap runs two single-row queries. Measured against the core +with 10009 members and 100035 messages in the group: `APIGroupMemberInfo` takes +1-2 ms, first call included, and does not depend on group size. + +## Technical Design + +### Which member is added to the model + +The member returned by `apiGetGroupMemberCode` is preferred over the one from +`apiGroupMemberInfo`. `APIGetGroupMemberCode` (Commands.hs:2016) clears +verification in the database when the peer's security code no longer matches +(`setGroupMemberVerified ... Nothing` / `setConnectionVerified ... Nothing`) and +returns the updated member. `apiGroupMemberInfo` runs before that, so its member +can still show the connection as verified. The previous code re-read all members +from the database *after* the code call, so the model saw the cleared state; +using the code call's member preserves that behaviour, and the verified shield +(GroupMemberInfoView.kt:736) does not go stale. + +### Guard on the open chat + +`upsertGroupMember` (ChatModel.kt:927) is a no-op when the open chat changed +while the two calls were in flight, which would leave `getGroupMember` null and +open an empty card. The explicit `chatModel.chatId.value != groupInfo.id` check +closes the modal path in that case instead. The previous code filled the model in +that race by writing the *previous* group's members into it — the stale data +hazard that `upsertGroupMember`'s own comment warns about (ChatModel.kt:936). + +### Duplicate protection is retained + +`#5462` ("improving group members loading to prevent crashes") made the wholesale +replacement safe against duplicated entries crashing `LazyColumn`. +`upsertGroupMember` carries the same protection: it clears the list when the +first member belongs to another group (ChatModel.kt:936) and looks the member up +by index before appending (ChatModel.kt:940, 956-966). + +## Consequences + +`chatModel.groupMembers` can now hold a partial list (previously it was either +empty or complete). This state already existed — channel creation writes a +relays-only list (ComposeView.kt:693) — and `membersLoaded` is deliberately left +`false`, so every screen that needs the full list still loads it: +`GroupChatInfoView.kt:117`, `ChannelMembersView`, `ChannelRelaysView.kt:38`, +`MemberSupportView.kt:45`, `addGroupMembers` (ChatView.kt:3217), and +`GroupMentions.kt:116` which checks the flag. + +One behaviour changes: + +- **Mention picker.** Until `@` triggers the load, the picker briefly lists the + members opened so far instead of nothing. Self-correcting. + +The relay removal warning is *not* affected. `activeRelays.size <= 1` +(GroupMemberInfoView.kt:250, GroupChatInfoView.kt:277) is computed from the +model, so it is already conservative while the member list is loading — with an +empty list the count is 0 and the warning fires. A partial list is a subset, so +the count can only move towards the correct value, and the opposite error is +impossible: a subset with two active relays implies the group has at least two. +It is also not reachable from the modified path — messages delivered through +relays have no item member (`CDChannelRcv`, Subscriber.hs:2161, `chatItemMember` +returns Nothing, Messages.hs:375) and their avatar opens chat info +(ChatView.kt:2150), not member info. A relay's profile is opened from the +channel members and relays screens, which load all members themselves +(GroupChatInfoView.kt:117, ChannelRelaysView.kt:38). + +The bulk refresh of all members that happened as a side effect of every tap is +gone; each screen refreshes its own data on open. + +## Alternatives Rejected + +- **Guard the load with `!membersLoaded`** — cures repeat taps, leaves the first + tap in a group costing seconds. +- **No model write, fall back to the chat item's member in the modal** — smaller + diff, but "Verify security code" (GroupMemberInfoView.kt:209) resolves the + member through the model too and would open empty; it would need a second + fallback in another file. +- **Move `apiGroupMemberInfo`/`apiGetGroupMemberCode` into `GroupMemberInfoView` + behind a `connectionLoaded` gate (full iOS parity, GroupMemberInfoView.swift:291)** — + opens the card with no API calls at all, but changes the view's signature, three + call sites and two previews, and makes the connection rows appear after the card + on every open. Worth doing separately; it does not affect the cost removed here. + +## Out of Scope + +- The first profile opened after entering a large group is still slower than the + rest. The core is not the cause (1-2 ms measured, agent call included); the tap + waits for the UI thread, which is busy composing the just-loaded page of + messages (`MergedItems.create` over all loaded items, ChatView.kt:1800) and + decoding avatars. Fixing it means making chat opening cheaper, or adding the + member to the model synchronously in the click handler as iOS does. +- Opening a second member's profile while one is already open does not switch the + card: `showInView` drives `AnimatedContent` from `modalCount` alone + (ModalView.kt:207), while `modalViews` is a plain list, so a close and open in + one frame leaves the target state unchanged. Pre-existing, unrelated to this + change. +- Message *Info* (ChatView.kt:706) still loads all members; it needs them to + resolve delivery recipients (ChatItemInfoView.kt:550). A narrower core API + would be required.