From 0853b5919caeb72d3276a02a0b7970cde4a1811f Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:29:28 +0000 Subject: [PATCH] core, ui: strip PR comments, fix Android profile-for-invitation issues - remove all comments introduced by this PR across the touched files, per review decision - fix the "New Profile" row being hidden behind the bottom app bar in one-handed UI mode once the profile list grows long enough to reach it - stop force-setting connChatUsed on reassignment, so the "keep unused invitation?" prompt correctly fires for a genuinely unused invitation - fix NewChatView's contactConnection state going stale after a reassignment (it never reflected the new connection), causing both the keep/delete decision and any later picker reopen to reference an already-superseded connection --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 -- .../ContextProfilePickerView.swift | 41 +------------- .../ios/Shared/Views/Helpers/ShareSheet.swift | 2 - .../Shared/Views/NewChat/NewChatView.swift | 55 ------------------- .../chat/simplex/common/model/SimpleXAPI.kt | 4 -- .../chat/simplex/common/views/WelcomeView.kt | 51 ----------------- .../chat/ComposeContextProfilePickerView.kt | 9 --- .../common/views/newchat/NewChatView.kt | 24 ++------ 8 files changed, 7 insertions(+), 183 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a580f3e235..7c1a1ded3a 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -280,10 +280,6 @@ func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? { } } -/// keepActiveUser creates the profile *without* activating it, for the invitation -/// pickers: the reassignment APIs resolve the prepared chat or connection under the active -/// user, so the profile that owns it has to stay active until it has moved. The response -/// is the created user either way, which is then not the active one. func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, keepActiveUser: Bool = false, ctrl: chat_ctrl? = nil) throws -> User { let r: ChatResponse0 = try chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp, keepActiveUser: keepActiveUser), ctrl: ctrl) if case let .activeUser(user) = r { return user } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift index 6393a0cde6..1dfcca05a1 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift @@ -137,7 +137,7 @@ struct ContextProfilePickerView: View { } } } - .frame(maxHeight: USER_ROW_SIZE * min(MAX_VISIBLE_USER_ROWS, CGFloat(users.count + 2))) // + 1 for incognito, + 1 for "Add profile" + .frame(maxHeight: USER_ROW_SIZE * min(MAX_VISIBLE_USER_ROWS, CGFloat(users.count + 2))) .onAppear { DispatchQueue.main.async { withAnimation(nil) { @@ -177,13 +177,10 @@ struct ContextProfilePickerView: View { if !incognitoDefault { listExpanded.toggle() } else if !busy { - // Gated like the sibling write in incognitoOption, and like both of - // the Kotlin ones: only expand/collapse stays live while busy. incognitoDefault = false listExpanded = false } } else if selectedUser != user { - // Only the branch that starts work; expand/collapse is local if busy { return } changingProfile = true changeProfile(user) @@ -251,11 +248,7 @@ struct ContextProfilePickerView: View { .disabled(busy) } - // Created without becoming active: changeProfile below resolves the prepared chat - // under the active user, so the profile that owns it must stay active until it moves. private func createProfileForChat(_ displayName: String, _ shortDescr: String?, _ image: String?) async throws { - // Atomic check-and-set: check-then-set lets two submits through, and @State - // must not be read off the main actor. let alreadyCreating = await MainActor.run { () -> Bool in if creatingProfile { return true } creatingProfile = true @@ -266,32 +259,20 @@ struct ContextProfilePickerView: View { let ownerUserId = await MainActor.run { chatModel.currentUser?.userId } let profile = Profile(displayName: displayName, fullName: "", shortDescr: shortDescr, image: image) let newUser = try apiCreateActiveUser(profile, keepActiveUser: true) - // Checked before refreshing the lists below: on this path the core has already - // activated the new profile, so they would disagree with chatModel.currentUser - // until the resync lands - and changeActiveUserAsync_ refreshes them anyway. if newUser.activeUser { - // An older remote host ignored keepActiveUser, so the reassignment would - // fail. Resync and report - not rethrown, or the form blames the creation. do { try await changeActiveUserAsync_(newUser.userId, viewPwd: nil) } catch { logger.error("changeActiveUserAsync_ error: \(responseError(error))") } await MainActor.run { - // Unconditional: the switch only removes this view when it succeeded 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 - // a retry with the same name is refused as a duplicate. if !chatModel.users.contains(where: { $0.user.userId == newUser.userId }) { chatModel.users.append(UserInfo(user: newUser, unreadCount: 0)) } if !users.contains(where: { $0.userId == newUser.userId }) { users.append(newUser) } - // Only if it switched, which the active user tells us: the prepared chat - // is then gone from the reloaded list and would render blank. if chatModel.currentUser?.userId == newUser.userId && chatModel.chatId == chat.id { chatModel.chatId = nil } @@ -299,11 +280,6 @@ struct ContextProfilePickerView: View { alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title")) return } - // Below the branch above, which returns and registers its own copy: appending here - // as well would leave two entries flagged activeUser. Above the guard below, which - // returns without refreshing either list - the profile exists by now, so it has to - // appear in both or the next attempt at the same name is a duplicate. - // users is otherwise only filled in onAppear. await MainActor.run { if !chatModel.users.contains(where: { $0.user.userId == newUser.userId }) { chatModel.users.append(UserInfo(user: newUser, unreadCount: 0)) @@ -313,10 +289,6 @@ struct ContextProfilePickerView: View { } } let updatedUsers = try? await listUsersAsync() - // changeProfile resolves the prepared chat under whatever is active when it runs, - // 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. let activeUserId = await MainActor.run { chatModel.currentUser?.userId } guard activeUserId == ownerUserId else { await MainActor.run { activeSheet = nil } @@ -326,24 +298,16 @@ struct ContextProfilePickerView: View { await MainActor.run { if let updatedUsers = updatedUsers { chatModel.users = updatedUsers - // Only filled in onAppear otherwise, so the new profile is missing here users = updatedUsers.map { $0.user }.filter { u in u.activeUser || !u.hidden } } - // changingProfile here too: the defer clears creatingProfile as soon as this returns activeSheet = nil changingProfile = true } changeProfile(newUser, dismissingSheet: true) } - /// [dismissingSheet] only on the create path, which closes the form first: the delay is - /// there to outlast a sheet dismissal, and on the plain row tap there is no sheet, so it - /// would just detach the error from the tap that caused it - master alerted at once. private func changeProfile(_ newUser: User, dismissingSheet: Bool = false) { func report(_ title: String, _ message: String? = nil) { - // Both hop to main: this runs inside the Task below, and showAlert presents a - // UIAlertController. alertAfterDismissal does it via asyncAfter, so the - // immediate path has to do it too rather than calling showAlert here. if dismissingSheet { alertAfterDismissal(title, message) } else { @@ -403,9 +367,6 @@ struct ContextProfilePickerView: View { if incognitoDefault { listExpanded.toggle() } else if !busy { - // As in profilerPickerUserOption: a failed changeProfile would leave the - // incognito default on while the picker still shows the chat profile. - // Only this branch - expanding and collapsing is local, as on Kotlin. incognitoDefault = true listExpanded = false } diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 5877fe5d03..ab9d434af3 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -79,8 +79,6 @@ func showAlertReturningController( return alert } -/// getTopViewController() keeps returning a sheet until its dismissal transition ends, so -/// an alert raised right after dismissing one is presented on it and dropped. func alertAfterDismissal(_ title: String, _ message: String? = nil) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { showAlert(title, message: message) diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 733d5e3979..e072a1ad54 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -428,11 +428,6 @@ private struct ActiveProfilePicker: View { dismiss() } } else { - // Backstop for a nil connection coming back without a throw: the - // row above cannot start this with contactConnection nil. Without - // it the status stays .switchingIncognito and busy leaves the - // whole picker - including "Add profile" - permanently dead. Both - // writes in one hop, so the re-entrant onChange sees .idle. await MainActor.run { profileSwitchStatus = .idle incognitoEnabled = !incognito @@ -492,10 +487,6 @@ private struct ActiveProfilePicker: View { } } } else { - // apiChangeConnectionUser returns nil rather than throwing when - // the retry is cancelled - offline. Without this the status stays - // .switchingUser, switchingProfileByTimeout latches, and the - // picker is left permanently behind its spinner. await MainActor.run { profileSwitchStatus = .idle selectedProfile = chatModel.currentUser ?? selectedProfile @@ -528,10 +519,6 @@ private struct ActiveProfilePicker: View { } - // The picker must be dead from the moment work starts: switchingProfileByTimeout only - // latches half a second later, to keep the spinner from flickering, and in that window - // a second row tap would run apiChangeConnectionUser against a connection that - // recreateConn is already deleting. private var busy: Bool { creatingProfile || switchingProfileByTimeout || profileSwitchStatus != .idle } @ViewBuilder private func viewBody() -> some View { @@ -561,8 +548,6 @@ private struct ActiveProfilePicker: View { private func profilerPickerUserOption(_ user: User) -> some View { Button { - // contactConnection as in incognitoOption: with nothing to change the handler - // does nothing and the backstop writes the app-wide default straight back. if selectedProfile == user && incognitoEnabled && contactConnection != nil { incognitoEnabled = false profileSwitchStatus = .switchingIncognito @@ -604,11 +589,7 @@ private struct ActiveProfilePicker: View { } } - // Created without activating, then routed through the same selectedProfile path as - // picking an existing profile, so the connection change and the switch cannot drift. private func createProfileForConnection(_ displayName: String, _ shortDescr: String?, _ image: String?) async throws { - // Atomic check-and-set on the main actor: a plain check-then-set leaves a window - // where two submits both pass, and @State must not be read off the main actor. let alreadyCreating = await MainActor.run { () -> Bool in if creatingProfile { return true } creatingProfile = true @@ -619,43 +600,24 @@ private struct ActiveProfilePicker: View { let ownerUserId = await MainActor.run { chatModel.currentUser?.userId } let profile = Profile(displayName: displayName, fullName: "", shortDescr: shortDescr, image: image) let newUser = try apiCreateActiveUser(profile, keepActiveUser: true) - // Checked before refreshing the lists below: on this path the core has already - // activated the new profile, so they would disagree with chatModel.currentUser - // until the resync lands - and changeActiveUserAsync_ refreshes them anyway. if newUser.activeUser { - // An older core ignored keepActiveUser, so the connection change would fail do { try await changeActiveUserAsync_(newUser.userId, viewPwd: nil) } catch { logger.error("changeActiveUserAsync_ error: \(responseError(error))") } - // Dismiss: the app now shows a different profile and the connection stayed - // with the previous one, so a second attempt would fail the same way. await MainActor.run { showAddProfile = false profileSwitchStatus = .idle - // Whatever the resync above ended up with - newUser if it switched, the - // previous profile if it threw. Not newUser unconditionally, or a failed - // switch leaves the checkmark on a profile that is not active. selectedProfile = chatModel.currentUser ?? selectedProfile - // Registered here rather than trusting the resync: changeActiveUserAsync_ - // calls apiSetActiveUserAsync and listUsersAsync before the MainActor.run - // that writes m.users, so a throw leaves that list untouched. if !chatModel.users.contains(where: { $0.user.userId == newUser.userId }) { chatModel.users.append(UserInfo(user: newUser, unreadCount: 0)) } - // And this snapshot is otherwise only filled in onAppear, so without it the - // picker lists the old profiles and no row shows a checkmark. profiles = chatModel.users.map { $0.user } } alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title")) return } - // Below the branch above, which returns: adding it there would leave two entries - // flagged activeUser, and its own resync refreshes the list anyway. Above the guard - // below, which returns without refreshing either list - the profile exists by now, - // so it has to appear in both or the next attempt at the same name is a duplicate. - // profiles is otherwise only filled in onAppear. await MainActor.run { if !chatModel.users.contains(where: { $0.user.userId == newUser.userId }) { chatModel.users.append(UserInfo(user: newUser, unreadCount: 0)) @@ -663,10 +625,6 @@ private struct ActiveProfilePicker: View { profiles = chatModel.users.map { $0.user } } let updatedUsers = try? await listUsersAsync() - // apiChangeConnectionUser resolves pccConnId under whatever is active when the - // 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. let activeUserId = await MainActor.run { chatModel.currentUser?.userId } guard activeUserId == ownerUserId else { await MainActor.run { showAddProfile = false } @@ -675,8 +633,6 @@ private struct ActiveProfilePicker: View { } await MainActor.run { if let updatedUsers = updatedUsers { chatModel.users = updatedUsers } - // Derived from chatModel.users, which already holds the new profile - without - // it selectedProfile below points at a profile that has no row in the picker. profiles = chatModel.users.map { $0.user } showAddProfile = false selectedProfile = newUser @@ -686,10 +642,6 @@ private struct ActiveProfilePicker: View { @ViewBuilder private func profilePicker() -> some View { let incognitoOption = Button { - // contactConnection too, as Kotlin's IncognitoUserOption checks: with nothing to - // change the handler below does nothing, and incognitoEnabled is a binding onto - // the app-wide default, so toggling it here would write that preference twice - // for an action that cannot happen. if !incognitoEnabled && contactConnection != nil { incognitoEnabled = true profileSwitchStatus = .switchingIncognito @@ -741,24 +693,17 @@ private struct ActiveProfilePicker: View { } } - // Outside the branch above: inside it the row would disappear whenever the - // active profile is filtered out by the search text. Only offered when there - // is a connection to move to the new profile. if contactConnection != nil { addProfileOption } } .opacity(switchingProfileByTimeout ? 0.4 : 1) - // Attached to the picker, not to the row: the row lives in a lazy container that - // may dispose it, taking the presented sheet with it. Unlike the compose picker - // this view is never replaced, so the picker is a stable enough owner. .sheet(isPresented: $showAddProfile) { NavigationView { CreateProfile(onSubmit: { displayName, shortDescr, image in try await createProfileForConnection(displayName, shortDescr, image) }, submitting: creatingProfile) } - // The submit Task is unstructured and SwiftUI will not cancel it on dismissal .interactiveDismissDisabled(creatingProfile) } } 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 d13c12b3fa..87eb656be6 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 @@ -880,10 +880,6 @@ object ChatController { return null } - /** [keepActiveUser] creates the profile *without* activating it, for the invitation - * pickers: the reassignment APIs resolve the prepared chat or connection under the - * active user, so the profile that owns it has to stay active until it has moved. The - * response is the created user either way, which is then not the active one. */ suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, pastTimestamp: Boolean = false, keepActiveUser: Boolean = false, ctrl: ChatCtrl? = null): User? { val r = sendCmd(rh, CC.CreateActiveUser(p, pastTimestamp = pastTimestamp, keepActiveUser = keepActiveUser), ctrl) if (r is API.Result && r.res is CR.ActiveUser) return r.res.user.updateRemoteHostId(rh) 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 db0256ccaf..5dd0ba86a1 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 @@ -60,10 +60,6 @@ fun bioFitsLimit(bio: String): Boolean { return chatJsonLength(bio) <= MAX_BIO_LENGTH_BYTES } -/** [onSubmit] replaces the default create-and-activate action, so the same form can be - * used to create a profile for an invitation - which must not switch the active user - * until the invitation has been moved onto it. Optional, so the existing call sites are - * untouched. */ @Composable fun CreateProfile(chatModel: ChatModel, close: () -> Unit, submitting: Boolean = false, onSubmit: ((displayName: String, shortDescr: String, image: String?) -> Unit)? = null) { val scope = rememberCoroutineScope() @@ -362,44 +358,23 @@ private fun CreateFirstProfileDesktop(chatModel: ChatModel, close: () -> Unit) { } } -/** Creates a profile for an invitation and hands it to [onCreated], which moves the - * invitation onto it. The profile is created *without* becoming active: the reassignment - * APIs resolve the prepared chat or connection under the active user, so the profile that - * owns the invitation has to stay active until [onCreated] has run. [onCreated] suspends - * so the in-flight flag covers the reassignment as well as the creation. */ fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) { if (chatModel.creatingProfileForInvitation.value) return - // fullscreen: center nulls chatId, closing the chat the invitation is in; end leaves the - // compose picker live in the pane beside the form; start disposes the picker that opened it. val modalManager = ModalManager.fullscreen if (modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) return modalManager.showCustomModal(id = ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE) { close -> - // Back, Esc and the back arrow must not dismiss the form while the profile is being - // created: it would exist with the invitation never moved onto it and nothing said, - // and the next attempt at the same name fails as a duplicate. iOS closes the same - // window with interactiveDismissDisabled. ModalView(close, enableClose = !chatModel.creatingProfileForInvitation.value) { - // Consume Back rather than leaving it unhandled: ModalView's own handler is disabled - // above, and Compose would then pass the event down to ChatView's, closing the chat - // behind the form. Registered after ModalView's, so it wins while it is enabled. BackHandler(enabled = chatModel.creatingProfileForInvitation.value, onBack = {}) CreateProfile(chatModel, close, submitting = chatModel.creatingProfileForInvitation.value) { displayName, shortDescr, image -> if (chatModel.creatingProfileForInvitation.value) return@CreateProfile 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. val job = withApi { try { val ownerUserId = chatModel.currentUser.value?.userId val profile = Profile(displayName.trim(), "", shortDescr.trim().ifEmpty { null }, image = image) val newUser = controller.apiCreateActiveUser(rhId, profile, keepActiveUser = true) ?: return@withApi if (newUser.activeUser) { - // An older remote host ignored the flag and activated it, so the reassignment - // would fail. Resync to what the host did and report it, with the form - // dismissed - the app is about to be showing a different profile. 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. suspend fun tryResync(): Boolean { return try { controller.changeActiveUser_(newUser.remoteHostId, newUser.userId, null) @@ -412,9 +387,6 @@ fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) { } } 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. chatModel.changingActiveUserMutex.withLock { if (chatModel.remoteHostId() == rhId && chatModel.users.none { it.user.userId == newUser.userId }) { val isActive = chatModel.currentUser.value?.userId == newUser.userId @@ -429,39 +401,20 @@ fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user)) return@withApi } - // Below the branch above, which returns and registers its own copy: appending - // here as well would leave two entries flagged activeUser. Above the - // check below, which returns without listing it - the profile exists by now, so - // it has to be in the list or the next attempt at the same name is a duplicate. - // Just this row, not a listUsers refresh: that clears and refills the whole list - // 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. 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. if (chatModel.currentUser.value?.userId != ownerUserId || chatModel.remoteHostId() != rhId) { - // Closed: the profile exists, so leaving the form up means the next Create - // fails on the duplicate name for as long as the name is unchanged. if (modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) close() AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user)) return@withApi } - // Form no longer on top. Don't move the invitation under a screen the user left - - // but the two ways to get here need different treatment. if (!modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) { if (modalManager.isModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) { - // Still there, just covered - a deep link or notification action opened a modal - // over it, which on Android shares the one stack. The form comes back with the - // name still typed, and Create would then fail as a duplicate, so say what - // happened rather than leaving the only silent branch in this function. AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user)) } else { - // Genuinely dismissed - don't report an error for something they chose. Log.i(TAG, "createProfileForInvitation: form closed before the invitation moved") } return@withApi @@ -470,10 +423,6 @@ fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) { try { onCreated(newUser) } catch (e: Exception) { - // Not a cancellation: this catch exists because changeActiveUser_ throws and - // withApi does not catch, so the global handler would close a modal or clear - // chatId with nothing said - but reporting a cancelled coroutine as a failure - // would be wrong, and rethrowing keeps it cancelling. if (e is CancellationException) throw e Log.e(TAG, "createProfileForInvitation: moving the invitation failed: ${e.stackTraceToString()}") 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 cf09a159ea..bce754a21f 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 @@ -43,8 +43,6 @@ fun ComposeContextProfilePickerView( val users = chatModel.users.map { it.user }.filter { u -> u.activeUser || !u.hidden } val listExpanded = remember { mutableStateOf(false) } val changingProfile = remember { mutableStateOf(false) } - // Creating stays set until the invitation has moved, which is after the form has closed - // and this picker is interactive again val busy = changingProfile.value || chatModel.creatingProfileForInvitation.value val maxHeightInPx = with(LocalDensity.current) { windowHeight().toPx() } @@ -104,8 +102,6 @@ fun ComposeContextProfilePickerView( chatMoved = true } } - // 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) { val switched = try { chatModel.controller.changeActiveUser_( @@ -132,8 +128,6 @@ fun ComposeContextProfilePickerView( } fun changeProfile(newUser: User) { - // Set before withApi, which dispatches - the rows would be live until it runs. - // The create path is covered by creatingProfileForInvitation instead. changingProfile.value = true withApi { try { changeProfileTo(newUser) } finally { changingProfile.value = false } @@ -153,9 +147,6 @@ fun ComposeContextProfilePickerView( Modifier .fillMaxWidth() .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp) - // busy gates the branches that change something, not the row: this picker has no - // spinner or dimming, so gating the row left it inert with no feedback and not even - // collapsible during a slow change. Expanding and collapsing is local. As on iOS. .clickable(onClick = { if (!chat.chatInfo.profileChangeProhibited) { if (selectedUser.value.userId == user.userId) { 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 bdea2a94f0..e1e1fc18cd 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 @@ -75,7 +75,7 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC * It will be dropped automatically when connection established or when user goes away from this screen. **/ if (chatModel.showingInvitation.value != null && ModalManager.start.openModalCount() <= 1) { - val conn = contactConnection.value + val conn = chatModel.showingInvitation.value?.conn if (chatModel.showingInvitation.value?.connChatUsed == false && conn != null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.keep_unused_invitation_question), @@ -205,8 +205,7 @@ private fun updateShownConnection(previousConnId: String, conn: PendingContactCo chatModel.showingInvitation.value = chatModel.showingInvitation.value?.copy( conn = conn, connId = conn.id, - connLink = conn.connLinkInv ?: CreatedConnLink("", null), - connChatUsed = true + connLink = conn.connLinkInv ?: CreatedConnLink("", null) ) } @@ -295,21 +294,19 @@ fun ActiveProfilePicker( showIncognito: Boolean = true ) { val switchingProfile = remember { mutableStateOf(false) } + val oneHandUI = remember { appPrefs.oneHandUI.state } val currentContactConnection = remember { mutableStateOf(contactConnection) } val incognito = remember { chatModel.showingInvitation.value?.conn?.incognito ?: controller.appPrefs.incognito.get() } val selectedProfile by remember { chatModel.currentUser } val searchTextOrPassword = rememberSaveable { search } - // Intentionally don't use derivedStateOf in order to NOT change an order after user was selected. - // Keyed on the count too, or a profile created from this picker is missing from it. val filteredProfiles = remember(searchTextOrPassword.value, chatModel.users.size) { filteredProfiles(chatModel.users.map { it.user }.sortedBy { !it.activeUser }, searchTextOrPassword.value) } var progressByTimeout by rememberSaveable { mutableStateOf(false) } - // Busy until the connection has moved onto the new profile, not just until it exists val busy = switchingProfile.value || chatModel.creatingProfileForInvitation.value LaunchedEffect(busy) { @@ -328,8 +325,6 @@ fun ActiveProfilePicker( 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) { @@ -337,8 +332,6 @@ fun ActiveProfilePicker( 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) val switched = try { @@ -430,9 +423,6 @@ fun ActiveProfilePicker( 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, updatedConn) @@ -460,7 +450,6 @@ fun ActiveProfilePicker( ) { LazyColumnWithScrollBar(Modifier.padding(top = topPaddingToContent(false)), userScrollEnabled = !busy) { item { - val oneHandUI = remember { appPrefs.oneHandUI.state } if (oneHandUI.value) { Spacer(Modifier.padding(top = DEFAULT_PADDING + 5.dp)) } @@ -501,15 +490,14 @@ fun ActiveProfilePicker( ProfilePickerUserOption(p) } } - // Outside the branch above, or the row disappears when search filters the active - // profile out. Only with a connection to move - the share list has none. if (contactConnection != null) { item { NewProfileOption() } } item { - Spacer(Modifier.imePadding().padding(bottom = DEFAULT_BOTTOM_PADDING)) + val bottomBarClearance = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp + Spacer(Modifier.imePadding().padding(bottom = DEFAULT_BOTTOM_PADDING + bottomBarClearance)) } } } @@ -597,7 +585,7 @@ private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contact search = search, close = close, rhId = rhId, - contactConnection = contactConnection.value + contactConnection = chatModel.showingInvitation.value?.conn ) }) }