diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 6a43a85e71..a580f3e235 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -129,16 +129,42 @@ func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool if inProgress == nil || inProgress?.boxedValue == true, case let .error(e) = r, let alert = retryableNetworkErrorAlert(e) { return await withCheckedContinuation { cont in + let resumeLock = NSLock() + var resumed = false + var watchdog: DispatchWorkItem? + var presentedAlert: UIAlertController? + func resumeOnce(_ result: APIResult?) { + resumeLock.lock() + let alreadyResumed = resumed + resumed = true + resumeLock.unlock() + if !alreadyResumed { + watchdog?.cancel() + cont.resume(returning: result) + } + } showRetryAlert( alert, onCancel: { _ in - cont.resume(returning: nil) + resumeOnce(nil) }, onRetry: { + watchdog?.cancel() let r1: APIResult? = await chatApiSendCmdWithRetry(cmd, bgTask: bgTask, bgDelay: bgDelay, inProgress: inProgress, retryNum: retryNum + 1) - cont.resume(returning: r1) + resumeOnce(r1) + }, + onPresented: { alert in + presentedAlert = alert } ) + let work = DispatchWorkItem { + resumeOnce(nil) + if let a = presentedAlert, a.presentedViewController == nil { + a.dismiss(animated: true) + } + } + watchdog = work + DispatchQueue.main.asyncAfter(deadline: .now() + 60, execute: work) } } else { return r @@ -146,9 +172,9 @@ func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool } @inline(__always) -func showRetryAlert(_ alert: (title: String, message: String), onCancel: @escaping (UIAlertAction) -> Void, onRetry: @escaping () async -> Void) { +func showRetryAlert(_ alert: (title: String, message: String), onCancel: @escaping (UIAlertAction) -> Void, onRetry: @escaping () async -> Void, onPresented: ((UIAlertController) -> Void)? = nil) { DispatchQueue.main.async { - showAlert( + let presented = showAlertReturningController( alert.title, message: alert.message, actions: {[ @@ -164,6 +190,7 @@ func showRetryAlert(_ alert: (title: String, message: String), onCancel: @escapi ) ]} ) + if let presented { onPresented?(presented) } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 54f8597ffc..521d966054 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -393,6 +393,7 @@ struct ComposeView: View { chat: chat, selectedUser: user ) + .id(chat.id) Divider() } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift index 27655bb251..f8e34137e5 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift @@ -12,6 +12,12 @@ import SimpleXChat let USER_ROW_SIZE: CGFloat = 60 let MAX_VISIBLE_USER_ROWS: CGFloat = 4.8 +private enum ContextProfilePickerSheet: Identifiable, Hashable { + case incognitoHelp + case addProfile + var id: Self { self } +} + struct ContextProfilePickerView: View { @ObservedObject var chat: Chat @EnvironmentObject var chatModel: ChatModel @@ -20,8 +26,7 @@ struct ContextProfilePickerView: View { @State private var users: [User] = [] @State private var listExpanded = false @State private var expandedListReady = false - @State private var showIncognitoSheet = false - @State private var showAddProfile = false + @State private var activeSheet: ContextProfilePickerSheet? = nil @State private var creatingProfile = false @State private var changingProfile = false @@ -34,9 +39,6 @@ struct ContextProfilePickerView: View { .map { $0.user } .filter { u in u.activeUser || !u.hidden } } - .sheet(isPresented: $showIncognitoSheet) { - IncognitoHelp() - } } private func viewBody() -> some View { @@ -47,15 +49,18 @@ struct ContextProfilePickerView: View { profilePicker() } } - // On the Group: the row and profilePicker() are both disposed while this is - // presented. Stacking sheets is supported from iOS 14.5; the target is 15. - .sheet(isPresented: $showAddProfile) { - NavigationView { - CreateProfile(onSubmit: { displayName, shortDescr, image in - try await createProfileForChat(displayName, shortDescr, image) - }, submitting: creatingProfile) + .sheet(item: $activeSheet) { sheet in + switch sheet { + case .incognitoHelp: + IncognitoHelp() + case .addProfile: + NavigationView { + CreateProfile(onSubmit: { displayName, shortDescr, image in + try await createProfileForChat(displayName, shortDescr, image) + }, submitting: creatingProfile) + } + .interactiveDismissDisabled(creatingProfile) } - .interactiveDismissDisabled(creatingProfile) } } @@ -224,7 +229,7 @@ struct ContextProfilePickerView: View { if chat.chatInfo.profileChangeProhibited { showCantChangeProfileAlert() } else { - showAddProfile = true + activeSheet = .addProfile } } label: { HStack { @@ -274,7 +279,7 @@ struct ContextProfilePickerView: View { } await MainActor.run { // Unconditional: the switch only removes this view when it succeeded - showAddProfile = false + activeSheet = nil // Registered here rather than trusting the resync: changeActiveUserAsync_ // calls apiSetActiveUserAsync and listUsersAsync before the MainActor.run // that writes m.users, so a throw leaves both lists without the profile and @@ -312,8 +317,8 @@ struct ContextProfilePickerView: View { // and a notification action can have switched it while we were creating. After the // await above, not before it: checked first, that await reopens the very window // this closes. Kotlin orders it the same way. - guard await MainActor.run({ chatModel.currentUser?.userId }) == ownerUserId else { - await MainActor.run { showAddProfile = false } + guard await MainActor.run(body: { chatModel.currentUser?.userId }) == ownerUserId else { + await MainActor.run { activeSheet = nil } alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title")) return } @@ -324,7 +329,7 @@ struct ContextProfilePickerView: View { users = updatedUsers.map { $0.user }.filter { u in u.activeUser || !u.hidden } } // changingProfile here too: the defer clears creatingProfile as soon as this returns - showAddProfile = false + activeSheet = nil changingProfile = true } changeProfile(newUser, dismissingSheet: true) @@ -366,6 +371,11 @@ struct ContextProfilePickerView: View { } do { try await changeActiveUserAsync_(newUser.userId, viewPwd: nil, keepingChatId: chat.id) + await MainActor.run { + if let chatId = chatModel.chatId, chatModel.getChat(chatId) == nil { + chatModel.chatId = nil + } + } } catch { report( NSLocalizedString("Error switching profile", comment: "alert title"), @@ -411,7 +421,7 @@ struct ContextProfilePickerView: View { .font(.system(size: 16)) .foregroundColor(theme.colors.primary) .onTapGesture { - showIncognitoSheet = true + activeSheet = .incognitoHelp } Spacer() diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 858ed6a75e..5877fe5d03 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -63,11 +63,20 @@ func showAlert( message: String? = nil, actions: () -> [UIAlertAction] = { [okAlertAction] } ) { - if let topController = getTopViewController() { - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - for action in actions() { alert.addAction(action) } - topController.present(alert, animated: true) - } + showAlertReturningController(title, message: message, actions: actions) +} + +@discardableResult +func showAlertReturningController( + _ title: String, + message: String? = nil, + actions: () -> [UIAlertAction] = { [okAlertAction] } +) -> UIAlertController? { + guard let topController = getTopViewController() else { return nil } + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + for action in actions() { alert.addAction(action) } + topController.present(alert, animated: true) + return alert } /// getTopViewController() keeps returning a sheet until its dismissal transition ends, so diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 85d55766a3..82c33519d0 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -474,11 +474,13 @@ private struct ActiveProfilePicker: View { do { try await changeActiveUserAsync_(profile.userId, viewPwd: profile.hidden ? trimmedSearchTextOrPassword : nil) await MainActor.run { + chatModel.showingInvitation = ShowingInvitation(pcc: conn, connChatUsed: false) profileSwitchStatus = .idle dismiss() } } catch { await MainActor.run { + chatModel.showingInvitation = ShowingInvitation(pcc: conn, connChatUsed: false) profileSwitchStatus = .idle alert = SomeAlert( alert: Alert( @@ -665,7 +667,7 @@ private struct ActiveProfilePicker: View { // selectedProfile handler runs, and a notification action can have switched it. // After the await above, not before it: checked first, that await reopens the very // window this closes. Kotlin orders it the same way. - guard await MainActor.run({ chatModel.currentUser?.userId }) == ownerUserId else { + guard await MainActor.run(body: { chatModel.currentUser?.userId }) == ownerUserId else { await MainActor.run { showAddProfile = false } alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title")) return diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 21fb45fc8a..d13c12b3fa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -654,14 +654,14 @@ object ChatController { val currentUser = changingActiveUserMutex.withLock { (if (toUserId != null) apiSetActiveUser(rhId, toUserId, viewPwd) else apiGetActiveUser(rhId)).also { chatModel.currentUser.value = it + val users = listUsers(rhId) + chatModel.users.clear() + chatModel.users.addAll(users) } } if (prevActiveUser?.hidden == true) { ntfManager.cancelNotificationsForUser(prevActiveUser.userId) } - val users = listUsers(rhId) - chatModel.users.clear() - chatModel.users.addAll(users) getUserChatData(rhId, keepingChatId = keepingChatId) val invitation = chatModel.callInvitations.values.firstOrNull { inv -> inv.user.userId == toUserId } if (invitation != null && currentUser != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index ecaf180a92..db0256ccaf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -47,9 +47,11 @@ import chat.simplex.common.views.usersettings.EditImageButton import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withLock import java.net.URI const val MAX_BIO_LENGTH_BYTES = 160 @@ -386,7 +388,7 @@ fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) { chatModel.creatingProfileForInvitation.value = true // On Main, like the pickers' own handlers: every call here suspends into IO, and the // chat model is updated on Main by the receiver loop. - withApi { + val job = withApi { try { val ownerUserId = chatModel.currentUser.value?.userId val profile = Profile(displayName.trim(), "", shortDescr.trim().ifEmpty { null }, image = image) @@ -398,13 +400,31 @@ fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) { if (modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) close() // changeActiveUser_, not changeActiveUser: the latter shows its own alert on // failure, which would stack with the one below reporting the same thing. - runCatching { controller.changeActiveUser_(newUser.remoteHostId, newUser.userId, null) } - .onFailure { Log.e(TAG, "createProfileForInvitation: resync failed: ${it.stackTraceToString()}") } + suspend fun tryResync(): Boolean { + return try { + controller.changeActiveUser_(newUser.remoteHostId, newUser.userId, null) + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "createProfileForInvitation: resync failed: ${e.stackTraceToString()}") + false + } + } + if (!tryResync()) tryResync() // Not relying on that resync to list it: changeActiveUser_ writes currentUser // inside its mutex before it calls listUsers, so a host that drops between the // two leaves the model pointing at a profile its own list does not contain. - if (chatModel.remoteHostId() == rhId && chatModel.users.none { it.user.userId == newUser.userId }) { - chatModel.users.add(UserInfo(newUser, 0)) + chatModel.changingActiveUserMutex.withLock { + if (chatModel.remoteHostId() == rhId && chatModel.users.none { it.user.userId == newUser.userId }) { + val isActive = chatModel.currentUser.value?.userId == newUser.userId + if (isActive) { + val updated = chatModel.users.map { if (it.user.activeUser) it.copy(user = it.user.copy(activeUser = false)) else it } + chatModel.users.clear() + chatModel.users.addAll(updated) + } + chatModel.users.add(UserInfo(newUser.copy(activeUser = isActive), 0)) + } } AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user)) return@withApi @@ -417,8 +437,10 @@ fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) { // from Main while changeActiveUser_ can be doing the same from withBGApi, and it // has nothing to add - newUser is the API's own record and a profile created a // moment ago has no unread messages. - if (chatModel.remoteHostId() == rhId && chatModel.users.none { it.user.userId == newUser.userId }) { - chatModel.users.add(UserInfo(newUser, 0)) + chatModel.changingActiveUserMutex.withLock { + if (chatModel.remoteHostId() == rhId && chatModel.users.none { it.user.userId == newUser.userId }) { + chatModel.users.add(UserInfo(newUser, 0)) + } } // onCreated resolves the invitation under whatever is active when it runs, and a // notification tap or a host switch can have changed that while we were creating. @@ -460,6 +482,16 @@ fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) { chatModel.creatingProfileForInvitation.value = false } } + withApi { + delay(60_000L) + if (job.isActive) { + job.cancelAndJoin() + if (job.isCancelled) { + AlertManager.shared.hideAlert() + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user)) + } + } + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt index 67ba47bd46..cf09a159ea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt @@ -23,6 +23,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.IncognitoOptionImage import chat.simplex.common.views.usersettings.IncognitoView import chat.simplex.res.MR +import kotlinx.coroutines.CancellationException import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -106,13 +107,22 @@ fun ComposeContextProfilePickerView( // Only switch if the chat moved, or the user ends up in another profile with the // invitation left behind. apiChangePrepared*User reports the failure itself. if (chatMoved) { - chatModel.controller.changeActiveUser_( - rhId = newUser.remoteHostId, - toUserId = newUser.userId, - viewPwd = null, - keepingChatId = chat.id - ) - if (chatModel.currentUser.value?.userId != newUser.userId) { + val switched = try { + chatModel.controller.changeActiveUser_( + rhId = newUser.remoteHostId, + toUserId = newUser.userId, + viewPwd = null, + keepingChatId = chat.id + ) + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "changeProfileTo: changeActiveUser_ failed: ${e.stackTraceToString()}") + false + } + if (!switched || chatModel.currentUser.value?.userId != newUser.userId || + chatModel.users.none { it.user.userId == newUser.userId && it.user.activeUser }) { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.switching_profile_error_title), String.format(generalGetString(MR.strings.switching_profile_error_message), newUser.chatViewName) @@ -249,10 +259,11 @@ fun ComposeContextProfilePickerView( Modifier .fillMaxWidth() .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp) - .clickable(enabled = !busy, onClick = { - // Live state the receiver loop flips, so it can turn true after the row was laid out + .clickable(onClick = { if (!chat.chatInfo.profileChangeProhibited) { - createProfileForInvitation(rhId) { changeProfileTo(it) } + if (!busy) { + createProfileForInvitation(rhId) { changeProfileTo(it) } + } } else { showCantChangeProfileAlert() } @@ -326,13 +337,11 @@ fun ComposeContextProfilePickerView( ProfilePickerUserOption(user) } - // Emitted last, so with reverseLayout it renders at the top of the expanded - // list - furthest from the compose box, with the current selection nearest. - // The divider goes under the row, not over it: reverseLayout flips the items, not the - // content of one, so emitting it first would draw a line along the list's top edge. item { NewProfileOption() - Divider(Modifier.padding(horizontal = DEFAULT_PADDING_HALF)) + if (otherUsers.isEmpty()) { + Divider(Modifier.padding(horizontal = DEFAULT_PADDING_HALF)) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 6bfbad52ef..e57fb87fec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -1666,11 +1666,13 @@ fun ComposeView( Column { val currentUser = chatModel.currentUser.value if (chat.chatInfo.nextConnectPrepared && !composeState.value.inProgress && currentUser != null) { - ComposeContextProfilePickerView( - rhId = rhId, - chat = chat, - currentUser = currentUser - ) + key(chat.id) { + ComposeContextProfilePickerView( + rhId = rhId, + chat = chat, + currentUser = currentUser + ) + } } val gInfo = (chat.chatInfo as? ChatInfo.Group)?.groupInfo diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index a7ef73fcf6..bdea2a94f0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -200,7 +200,8 @@ private fun CreatingLinkProgressView() { DefaultProgressView(stringResource(MR.strings.creating_link)) } -private fun updateShownConnection(conn: PendingContactConnection) { +private fun updateShownConnection(previousConnId: String, conn: PendingContactConnection) { + if (chatModel.showingInvitation.value?.connId != previousConnId) return chatModel.showingInvitation.value = chatModel.showingInvitation.value?.copy( conn = conn, connId = conn.id, @@ -294,6 +295,7 @@ fun ActiveProfilePicker( showIncognito: Boolean = true ) { val switchingProfile = remember { mutableStateOf(false) } + val currentContactConnection = remember { mutableStateOf(contactConnection) } val incognito = remember { chatModel.showingInvitation.value?.conn?.incognito ?: controller.appPrefs.incognito.get() } @@ -323,27 +325,38 @@ fun ActiveProfilePicker( try { var updatedConn: PendingContactConnection? = null - if (contactConnection != null) { - updatedConn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId) + val conn = currentContactConnection.value + if (conn != null) { + updatedConn = controller.apiChangeConnectionUser(rhId, conn.pccConnId, user.userId) // Not moved - leave the picker open rather than stranding a profile just created // for this invitation. This call provisions a new queue, so it is what fails offline. if (updatedConn == null) return + currentContactConnection.value = updatedConn withContext(Dispatchers.Main) { chatModel.chatsContext.updateContactConnection(rhId, updatedConn) - updateShownConnection(updatedConn) + updateShownConnection(conn.id, updatedConn) } } // After the move, not before: the picker now stays open on failure, and clearing the // app-wide default there would leave it off with Incognito still ticked in the picker appPreferences.incognito.set(false) - controller.changeActiveUser_( - rhId = user.remoteHostId, - toUserId = user.userId, - viewPwd = if (user.hidden) searchTextOrPassword.value else null - ) + val switched = try { + controller.changeActiveUser_( + rhId = user.remoteHostId, + toUserId = user.userId, + viewPwd = if (user.hidden) searchTextOrPassword.value else null + ) + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "selectProfileAsync: changeActiveUser_ failed: ${e.stackTraceToString()}") + false + } - if (chatModel.currentUser.value?.userId != user.userId) { + if (!switched || chatModel.currentUser.value?.userId != user.userId || + chatModel.users.none { it.user.userId == user.userId && it.user.activeUser }) { AlertManager.shared.showAlertMsg(generalGetString( MR.strings.switching_profile_error_title), String.format(generalGetString(MR.strings.switching_profile_error_message), user.chatViewName) @@ -408,20 +421,22 @@ fun ActiveProfilePicker( title = stringResource(MR.strings.incognito), selected = incognito, onSelected = { - if (incognito || busy || contactConnection == null) return@ProfilePickerOption + val conn = currentContactConnection.value + if (incognito || busy || conn == null) return@ProfilePickerOption switchingProfile.value = true withApi { try { - val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true) - if (conn != null) { + val updatedConn = controller.apiSetConnectionIncognito(rhId, conn.pccConnId, true) + if (updatedConn != null) { + currentContactConnection.value = updatedConn // Only once the connection is actually incognito, as on the profile path // above: set before the call, a failure leaves the app-wide default on and // the next connection silently uses a random profile. appPreferences.incognito.set(true) withContext(Dispatchers.Main) { - chatModel.chatsContext.updateContactConnection(rhId, conn) - updateShownConnection(conn) + chatModel.chatsContext.updateContactConnection(rhId, updatedConn) + updateShownConnection(conn.id, updatedConn) } close() }