diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 91b3270dea..b35e828962 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -1205,12 +1205,13 @@ fun SynchronizeConnectionButtonForce(syncConnectionForce: () -> Unit) { } @Composable -fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) { +fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit, disabled: Boolean = false) { SettingsActionItem( if (contactVerified) painterResource(MR.images.ic_verified_user) else painterResource(MR.images.ic_shield), stringResource(if (contactVerified) MR.strings.view_security_code else MR.strings.verify_security_code), click = onClick, iconColor = MaterialTheme.colors.secondary, + disabled = disabled, ) } 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 7e43db2e98..091924e3c7 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 @@ -499,6 +499,15 @@ fun ChatView( showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> hideKeyboard(view) groupMembersJob.cancel() + val connStats = mutableStateOf(null) + val connectionCode = mutableStateOf(null) + val connectionLoaded = mutableStateOf(false) + if (chatsCtx.secondaryContextFilter == null) { + ModalManager.end.closeModals() + } + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close -> + GroupMemberInfoView(chatRh, groupInfo, member, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = false, close = close, closeAll = close) + } groupMembersJob = scope.launch(Dispatchers.Default) { val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) val stats = r?.second @@ -513,15 +522,9 @@ fun ChatView( withContext(Dispatchers.Main) { chatModel.chatsContext.upsertGroupMember(chatRh, groupInfo, updatedMember) } - - if (chatsCtx.secondaryContextFilter == null) { - ModalManager.end.closeModals() - } - ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close -> - remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close = close, closeAll = close) - } - } + connStats.value = stats + connectionCode.value = code + connectionLoaded.value = true } }, loadMessages = { chatId, pagination, visibleItemIndexes -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 49e7ac6db7..d14da9dc33 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -123,6 +123,15 @@ fun ModalData.GroupChatInfoView( } }, showMemberInfo = { member, groupRelay -> + val connStats = mutableStateOf(null) + val connectionCode = mutableStateOf(null) + val connectionLoaded = mutableStateOf(false) + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent -> + GroupMemberInfoView(rhId, groupInfo, member, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) { + closeCurrent() + close() + } + } withBGApi { val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, member.groupMemberId) val stats = r?.second @@ -132,14 +141,9 @@ fun ModalData.GroupChatInfoView( } else { member to null } - ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent -> - remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) { - closeCurrent() - close() - } - } - } + connStats.value = stats + connectionCode.value = code + connectionLoaded.value = true } }, editGroupProfile = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 3e5f64659b..885d3de4e6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -45,10 +45,11 @@ import kotlinx.coroutines.* fun GroupMemberInfoView( rhId: Long?, groupInfo: GroupInfo, - member: GroupMember, + groupMember: GroupMember, scrollToItemId: MutableState, - connectionStats: ConnectionStats?, - connectionCode: String?, + connStats: MutableState, + connectionCode: State, + connectionLoaded: State, chatModel: ChatModel, openedFromSupportChat: Boolean, groupRelay: GroupRelay? = null, @@ -60,7 +61,10 @@ fun GroupMemberInfoView( } BackHandler(onBack = close) val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId } - val connStats = remember { mutableStateOf(connectionStats) } + // the passed member is shown until the loaded one is added to the model, so that the profile opens without waiting for the core + val member = remember(groupMember.groupMemberId) { + derivedStateOf { chatModel.getGroupMember(groupMember.groupMemberId) ?: groupMember } + }.value val developerTools = chatModel.controller.appPrefs.developerTools.get() var progressIndicator by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -91,7 +95,8 @@ fun GroupMemberInfoView( connStats, newRole, developerTools, - connectionCode, + connectionCode.value, + connectionLoaded.value, groupRelay = groupRelay, getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { contactId -> @@ -101,6 +106,7 @@ fun GroupMemberInfoView( } }, createMemberContact = { + val connectionStats = connStats.value if (member.sendMsgEnabled) { withBGApi { progressIndicator = true @@ -209,7 +215,7 @@ fun GroupMemberInfoView( remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> VerifyCodeView( mem.displayName, - connectionCode, + connectionCode.value, mem.verified, verify = { code -> chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r -> @@ -368,6 +374,7 @@ fun GroupMemberInfoLayout( newRole: MutableState, developerTools: Boolean, connectionCode: String?, + connectionLoaded: Boolean, groupRelay: GroupRelay? = null, getContactChat: (Long) -> Chat?, openDirectChat: (Long) -> Unit, @@ -538,22 +545,23 @@ fun GroupMemberInfoLayout( SectionDividerSpaced() } + val memberConnected = member.memberActive || (groupInfo.useRelays && member.memberCurrent) val showMemberSupportChat = !openedFromSupportChat && groupInfo.membership.memberRole >= GroupMemberRole.Moderator && member.memberRole != GroupMemberRole.Relay && ((groupInfo.fullGroupPreferences.support.on && member.memberRole < GroupMemberRole.Moderator) || member.supportChat != null) - val canVerifyCode = connectionCode != null && member.memberRole != GroupMemberRole.Relay + // the same condition decides whether the code is requested, so the row is shown disabled until it arrives + val canVerifyCode = member.memberRole != GroupMemberRole.Relay && (connectionCode != null || !connectionLoaded) val canSyncConn = cStats != null && cStats.ratchetSyncAllowed - if ((member.memberActive || (groupInfo.useRelays && member.memberCurrent)) - && (showMemberSupportChat || canVerifyCode || canSyncConn)) { + if (memberConnected && (showMemberSupportChat || canVerifyCode || canSyncConn)) { SectionView { if (showMemberSupportChat) { SupportChatButton() } if (canVerifyCode) { - VerifyCodeButton(member.verified, verifyClicked) + VerifyCodeButton(member.verified, verifyClicked, disabled = connectionCode == null) } if (canSyncConn) { SynchronizeConnectionButton(syncMemberConnection) @@ -655,6 +663,20 @@ fun GroupMemberInfoLayout( SimplexServers(stringResource(MR.strings.sending_via), sndServers) } } + } else if (!connectionLoaded && memberConnected) { + SectionDividerSpaced() + SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { + // the status is set from the same receiving queues as the servers below, so it is reserved with them + SectionItemView { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(MR.strings.network_status)) + Icon(painterResource(MR.images.ic_info), stringResource(MR.strings.network_status), tint = MaterialTheme.colors.secondary) + } + } + SwitchAddressButton(disabled = true, switchAddress = {}) + SimplexServers(stringResource(MR.strings.receiving_via), emptyList()) + SimplexServers(stringResource(MR.strings.sending_via), emptyList()) + } } val connFailedErr = member.activeConn?.connFailedErr @@ -1117,6 +1139,7 @@ fun PreviewGroupMemberInfoLayout() { newRole = remember { mutableStateOf(GroupMemberRole.Member) }, developerTools = false, connectionCode = "123", + connectionLoaded = true, getContactChat = { Chat.sampleData }, openDirectChat = {}, createMemberContact = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt index 1ac6d6f048..0da3c46abb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt @@ -76,6 +76,15 @@ fun MemberSupportChatAppBar( navigationButton = { NavigationButtonBack(onBackClicked) }, title = { MemberSupportChatToolbarTitle(scopeMember_) }, onTitleClick = { + val connStats = mutableStateOf(null) + val connectionCode = mutableStateOf(null) + val connectionLoaded = mutableStateOf(false) + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent -> + GroupMemberInfoView(rhId, groupInfo, scopeMember_, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = true, close = closeCurrent) { + closeCurrent() + close() + } + } withBGApi { val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, scopeMember_.groupMemberId) val stats = r?.second @@ -85,14 +94,9 @@ fun MemberSupportChatAppBar( } else { null } - ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent -> - remember { derivedStateOf { chatModel.getGroupMember(scopeMember_.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = true, close = closeCurrent) { - closeCurrent() - close() - } - } - } + connStats.value = stats + connectionCode.value = code + connectionLoaded.value = true } }, onTop = !oneHandUI.value || !chatBottomBar.value, diff --git a/plans/2026-09-10-member-profile-open-without-core.md b/plans/2026-09-10-member-profile-open-without-core.md new file mode 100644 index 0000000000..01e9cebd3f --- /dev/null +++ b/plans/2026-09-10-member-profile-open-without-core.md @@ -0,0 +1,213 @@ +# Open Member Profile Without Waiting for the Core + +## Context + +`#7388` removed `apiListMembers` from the member profile tap, which was O(group +size) and took seconds in a group with 10000 members. It did not remove the +dependency on the core. + +`showMemberInfo` (ChatView.kt:499) still awaited two calls before the modal was +created: + +```kotlin +val r = chatModel.controller.apiGroupMemberInfo(...) // awaited +val (updatedMember, code) = if (...) { + val memCode = chatModel.controller.apiGetGroupMemberCode(...) // awaited + ... +} +... +ModalManager.end.showModalCloseable(...) { ... } // only now +``` + +Both are single-row queries, 1-2 ms when the core is idle, which is why the tap +looks instant in testing. But `sendCmd` is serialized against everything else the +core is doing, so while it is busy - startup, a batch of incoming events, a long +database operation - the tap produces nothing at all until the core drains. The +profile opens at the speed of the core rather than the speed of the UI. + +The same wait is in `GroupChatInfoView.kt:125` and `MemberSupportChatView.kt:78`. + +## Root Cause + +The modal was created inside the coroutine, after both awaits, and the loaded +values were passed into the view as plain parameters: + +```kotlin +fun GroupMemberInfoView( + ... + connectionStats: ConnectionStats?, + connectionCode: String?, + ... +) +``` + +A plain parameter can only be supplied by a caller that has already waited. + +Each call site also resolved the member through the model and rendered nothing +when it was absent: + +```kotlin +remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> ... } +``` + +In a large group where members were never loaded, that is an empty card - which +is why `#7388` had to write the member into the model *before* showing the modal. + +## Solution + +Open the modal first and let the loaded values arrive afterwards. The two +parameters become state the caller creates at the tap and fills in when the +queries return: + +```kotlin +val connStats = mutableStateOf(null) +val connectionCode = mutableStateOf(null) +ModalManager.end.showModalCloseable(...) { close -> + GroupMemberInfoView(chatRh, groupInfo, member, scrollToItemId, connStats, connectionCode, ...) +} +groupMembersJob = scope.launch(Dispatchers.Default) { + ...unchanged... + connStats.value = r?.second + connectionCode.value = code +} +``` + +The card is shown from the member that is already known at the tap - from the +chat item, or from the members list - with the model's copy preferred once it is +there: + +```kotlin +val member = remember(groupMember.groupMemberId) { + derivedStateOf { chatModel.getGroupMember(groupMember.groupMemberId) ?: groupMember } +}.value +``` + +The `?: groupMember` fallback is what removes the dependency: the card no longer +needs the model to contain the member, so nothing has to happen before the modal +opens. + +## Technical Design + +### The state is created at the tap, not in the view + +`ModalManager.showInView` drives `AnimatedContent` from `modalCount` alone +(ModalView.kt:207), so the card is disposed while a sub-screen is open and +re-composed on return. State held by `remember` inside the view would lose the +loaded values there, and the rows would disappear. A `MutableState` created in +the click handler is captured by the modal lambda and outlives every +recomposition of the card, with no storage of its own. + +### The loaded code cannot come from the model + +`APIGetGroupMemberCode` returns the live code beside the member +(Commands.hs:2047); the member it returns carries only the verification record, +which the core *clears* when the code no longer matches. So the code has to be +held by the UI - it cannot be read back off `member.activeConn`. + +### The rows are reserved, not gated and not inserted + +iOS wraps everything below the action buttons in `if connectionLoaded` +(GroupMemberInfoView.swift:123-288) - the action section, Address, Member, +Servers, Connection failed and For console. Until both queries return its card is +a header and three buttons over blank space. That is tolerable when the core +answers in 1-2 ms; it is the wrong trade for the case this change exists for, +where the core is busy and the wait is what the user sees. + +Letting each row appear as its data arrives is no better: the card is complete +from the first frame but grows under the finger. + +So the rows that depend on the load are rendered from the first frame in their +final positions, disabled, and enabled in place when the data arrives: + +```kotlin +val canVerifyCode = member.memberRole != GroupMemberRole.Relay && (connectionCode != null || !connectionLoaded) +... +VerifyCodeButton(member.verified, verifyClicked, disabled = connectionCode == null) +``` + +`SectionItemView` already drops the `clickable` modifier when `disabled` +(Section.kt:213), so a disabled row is inert and identical in size to the enabled +one - nothing moves when it is enabled. + +Only rows that can be predicted locally are reserved: + +- **Verify security code.** The enclosing condition is already + `memberActive || (useRelays && memberCurrent)`, so inside it the loader's + `getCode` reduces to `memberRole != Relay` - known from the member at the tap. +- **Servers.** Reserved as a whole section with a disabled "Change receiving + address" and empty Receiving/Sending rows, for a member the same condition says + is connected. +- **Fix connection is not reserved.** `canSyncConn` needs + `cStats.ratchetSyncAllowed`, which is false except during ratchet desync, so a + placeholder for it would disappear on nearly every open - the same jump in the + opposite direction. + +A third state, `connectionLoaded`, is created at the tap beside the other two and +set when the queries return, including on failure. Without it a failed load would +leave the row disabled forever, because "no code yet" and "no code at all" look +the same. + +Network status is reserved with them. It looks like agent state that cannot be +predicted, but `connSubStatus` (simplexmq Agent.hs:2736) returns `Just` whenever +`rcvQueuesInfo` is non-empty and `Nothing` only when it is empty - the same list +that decides whether "Receiving via" is rendered. The two rows appear together, +so reserving one and not the other is what leaves a gap. + +The remaining shift is "Abort changing address", which renders only while +`rcvSwitchStatus != null` on a receiving queue. That is a switch the user +started, so it is absent in normal use and reserving it would add a row that +disappears on nearly every open. + +iOS needs its gate for a reason Kotlin does not have: `newRole` is `@State` +initialised to a placeholder `.member` and corrected inside `.task` +(GroupMemberInfoView.swift:30, :297), so the role picker must not render before +that runs. Kotlin seeds it from the member inline - +`remember { mutableStateOf(member.memberRole) }` - and is correct on the first +frame. + +### The chat-change guard is kept + +`#7388` added `chatModel.chatId.value != groupInfo.id` so that a load finishing +after the chat changed does not write to the model. The modal now opens before +the guard is reached, and `KeyChangeEffect` (:56) closes the card on chat change +as before, so the guard is left exactly as it was. + +`groupMembersJob.cancel()` is kept in the tap handler: `info` (ChatView.kt:390) +preloads members *before* showing its modal, so without the cancel a pending chat +info load would open its modal on top of the member card. + +## Consequences + +- The card renders from the member known at the tap, so it can be marginally + staler for the duration of the load. iOS has the same window - it renders the + header before `connectionLoaded`. +- "Verify security code" and the Servers section are shown disabled until the + queries return, so the card does not change height as they arrive. This is a + third behaviour, matching neither the old Kotlin (rows inserted as data landed) + nor iOS (whole body hidden until loaded); porting it to iOS would be the way to + converge them. +- "Fix connection" still appears on arrival when the ratchet needs syncing. It is + rare enough that reserving space for it would move the card more often than it + saves. +- "Send message" for a member with `!sendMsgEnabled` does nothing if tapped + before the stats arrive. iOS is identical: `createMemberContactButton` has the + same `else if let connStats` with no else, and renders the action buttons + before `connectionLoaded`. +- The modal is now opened from the click handler on the main thread instead of + from `Dispatchers.Default` / `withBGApi`. All three callers are UI click + handlers, so this is strictly safer - `modalViews` is a plain `ArrayList`. + +## Out of Scope + +- **Collapsing the three copies of the fetch.** The same two queries and the same + condition are still written out at all three call sites. That duplication has + already drifted once - `#7255` widened the condition for channel members in two + of the three and in iOS, and `ChatView.kt` was left behind until `#7486` - and + moving the load into the view, as iOS has done since `#5008`, would remove it. + It is a restructure rather than a fix, so it belongs in its own change. +- Message *Info* (ChatView.kt:706) still loads all members; it needs them to + resolve delivery recipients. A narrower core API would be required. +- Opening a second member's profile while one is already open does not switch the + card: `closeModals()` sets `modalCount` to 0 and `showCustomModal` back to 1, so + `AnimatedContent`'s target never changes. The close and open are back to back in + both the old and the new code, so this is neither introduced nor worsened.