diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift index c46790c74d..180c70b3b1 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift @@ -47,12 +47,9 @@ struct ContextProfilePickerView: View { profilePicker() } } - // Attached here, not to the row and not to profilePicker(): the row lives in a - // lazy container that may dispose it, and profilePicker() itself is replaced the - // moment listExpanded flips - which changeProfile does while this sheet is still - // dismissing, and which an incoming event can do at any time by setting - // profileChangeProhibited. This Group survives both. Stacking it with the - // IncognitoHelp sheet on body is fine from iOS 14.5; the app targets 15. + // On the Group, not the row or profilePicker(): both are disposed while this is + // presented - the row by its lazy container, the picker when listExpanded flips. + // Stacking with body's IncognitoHelp sheet is fine from iOS 14.5; target is 15. .sheet(isPresented: $showAddProfile) { NavigationView { CreateProfile(onSubmit: { displayName, shortDescr, image in @@ -104,9 +101,7 @@ struct ContextProfilePickerView: View { let otherUsers = users .filter { u in u.userId != selectedUser.userId } - // Descending, as every other profile list sorts: a profile - // that was never activated has active_order 0 and belongs - // at the end, not the front. + // Descending, as every other profile list sorts .sorted(using: KeyPathComparator(\.activeOrder, order: .reverse)) ForEach(otherUsers) { p in profilerPickerUserOption(p) @@ -183,8 +178,7 @@ struct ContextProfilePickerView: View { listExpanded = false } } else if selectedUser != user { - // Only the branch that starts work is guarded - expanding and - // collapsing the list is local and stays available. + // Only the branch that starts work: expand/collapse is local if busy { return } changingProfile = true changeProfile(user) @@ -268,22 +262,16 @@ struct ContextProfilePickerView: View { await MainActor.run { if let updatedUsers = updatedUsers { chatModel.users = updatedUsers - // This view's own list is otherwise only filled in onAppear, so without - // this the profile just created is missing from the picker if the - // reassignment below fails - and the row count the frame is sized from - // is one short. + // Otherwise only filled in onAppear, so the profile just created is + // missing from the picker, and the row count the frame uses is one short. users = updatedUsers.map { $0.user }.filter { u in u.activeUser || !u.hidden } } } if newUser.activeUser { - // The core did not honour keepActiveUser and activated the profile - an older - // remote host ignoring the unknown field. Reassigning would now fail, so - // resync to what the host actually did and report it. The failure is the - // switch, not the creation, so it is not rethrown into the form's "error - // creating profile" handler. - // let, not var: MainActor.run's body is @Sendable, and capturing a mutable - // local in one is diagnosed under strict concurrency. Assigned on both - // branches, so it is definitely initialised. + // An older remote host ignored keepActiveUser and activated it, so the + // reassignment would fail. Resync to what the host did and report it - not + // rethrown, or the form reports it as a failure to create the profile. + // let, not var: MainActor.run's body is @Sendable and cannot capture a mutable local let switched: Bool do { try await changeActiveUserAsync_(newUser.userId, viewPwd: nil) @@ -293,10 +281,8 @@ struct ContextProfilePickerView: View { switched = false } await MainActor.run { - // Only when the switch actually happened: the prepared chat then belongs - // to a profile that is no longer active, so it is absent from the reloaded - // list and a pushed chat view renders blank. If the switch failed nothing - // moved and the chat is still fine - closing it would be the regression. + // Only if the switch happened: the chat is then absent from the reloaded + // list and would render blank. If it failed, the chat is still fine. if switched && chatModel.chatId == chat.id { chatModel.chatId = nil } } // The switch replaces the chat list, which removes this view - and the sheet diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 91730085d1..8478ac299d 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -428,12 +428,10 @@ private struct ActiveProfilePicker: View { dismiss() } } else { - // Only reachable with no connection - apiSetConnectionIncognito - // throws rather than returning nil. Nothing was changed, so just - // release the status, which otherwise stays .switchingIncognito - // and latches the picker behind a spinner with hit testing off. - // incognitoEnabled is left alone: it is bound to the app-wide - // default, and writing it marks the invitation as used. + // Nothing changed, so just release the status, which otherwise + // latches the picker behind a spinner with hit testing off. + // incognitoEnabled is the app-wide default: writing it here + // would mark the invitation as used. await MainActor.run { profileSwitchStatus = .idle } } } catch { @@ -488,13 +486,9 @@ private struct ActiveProfilePicker: View { } } } else { - // No connection, or apiChangeConnectionUser returned nothing: - // nothing was moved, so don't switch or dismiss. Reset the - // status, which otherwise latches the picker into its spinner - // with hit testing off, and put selectedProfile back on the - // active user as the catch below does - otherwise the profile - // that was not switched to keeps the checkmark, and tapping it - // does nothing because the view thinks it is already selected. + // Nothing moved, so don't switch or dismiss. Reset the status, + // and the selection as the catch below does - otherwise the + // profile keeps the checkmark and tapping it does nothing. await MainActor.run { profileSwitchStatus = .idle if let currentUser = chatModel.currentUser { @@ -529,10 +523,8 @@ private struct ActiveProfilePicker: View { } - // switchingProfileByTimeout only latches half a second after a switch starts, to keep - // the spinner from flickering on fast switches - but input has to be blocked from the - // moment the work begins, or a second tap inside that window starts a competing - // connection change and user switch. + // switchingProfileByTimeout latches half a second late, to avoid spinner flicker, but + // input must be blocked from the start or a second tap starts a competing switch. private var busy: Bool { creatingProfile || switchingProfileByTimeout || profileSwitchStatus != .idle } @ViewBuilder private func viewBody() -> some View { @@ -601,9 +593,6 @@ private struct ActiveProfilePicker: View { Spacer() } } - // profileSwitchStatus, not just switchingProfileByTimeout: that only latches half - // a second later, and creatingProfile is cleared as soon as the switch is handed - // to the selectedProfile handler - leaving the row live in between. .disabled(busy) } @@ -628,10 +617,8 @@ private struct ActiveProfilePicker: View { profiles = chatModel.users.map { $0.user } } if newUser.activeUser { - // Older core ignored keepActiveUser and switched instead - resync rather than - // attempting a connection change that would now fail. The failure is the - // switch, not the creation, so it is not rethrown into the form's "error - // creating profile" handler. + // An older core ignored keepActiveUser and switched instead, so the connection + // change would fail. Not rethrown, or the form reports it as a creation failure. do { try await changeActiveUserAsync_(newUser.userId, viewPwd: nil) } catch { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index def4e4798d..e7a4b859f7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -122,12 +122,9 @@ object ChatModel { val incompleteInitializedDbRemoved = mutableStateOf(false) // map of connections network statuses, key is agent connection id val switchingUsersAndHosts = mutableStateOf(false) - /** True from the moment a profile is submitted in the "add profile for this invitation" - * form until the invitation has been moved onto it. Lives on the model rather than in - * the picker that starts it: on Android every ModalManager placement is one stack rendering only - * its top entry, so the one-time link picker - itself a modal - is disposed while the - * form is above it and returns with every remembered flag reset. (The compose picker is - * not a modal and does survive, on both platforms.) */ + /** Set while a profile is being created for an invitation and moved onto it. On the + * model because on Android the picker that starts it is itself a modal, and is disposed + * while the create-profile form is above it - a remembered flag would come back false. */ val creatingProfileForInvitation = mutableStateOf(false) // current chat 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 ddd333eaa3..41d98cbdc1 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 @@ -46,11 +46,9 @@ import chat.simplex.common.views.usersettings.DeleteImageButton import chat.simplex.common.views.usersettings.EditImageButton import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.net.URI const val MAX_BIO_LENGTH_BYTES = 160 @@ -349,88 +347,59 @@ 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 - which is also why -// onCreated is suspending, so the in-flight flag covers the reassignment and not just the -// creation. +/** 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) { - // ModalManager.fullscreen on purpose. center nulls chatId on desktop, closing the very - // chat a prepared invitation is in; end widens the window for good and leaves the - // compose picker live in the pane beside the form; start would work for the one-time - // link picker but disposes it, losing what was typed in its search box. fullscreen is - // an opaque Surface over every pane, so no picker can be operated while the form is up - // and none of them is torn down. On Android all four are the same manager anyway. - // Not while one is still being created: the in-flight job identifies its own form only - // by this id, so a second form opened underneath it would be the one closed and the - // wrong profile handed over. if (chatModel.creatingProfileForInvitation.value) return - // Two taps before the modal renders would otherwise stack two modals sharing one id, - // after which close() could dismiss the wrong one. NotClosing, or re-opening the form - // right after backing out of it is a silent no-op until the animation ends. + // 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.hasModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) return + if (modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) return modalManager.showModalCloseable(id = ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE) { close -> CreateProfile(submitting = chatModel.creatingProfileForInvitation.value) { displayName, shortDescr, image -> if (chatModel.creatingProfileForInvitation.value) return@CreateProfile chatModel.creatingProfileForInvitation.value = true - withBGApi { + // 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 { try { - // The reassignment in onCreated resolves the invitation under whatever is active - // then, so remember what owns it now and check nothing moved underneath us. val ownerUserId = chatModel.currentUser.value?.userId val profile = Profile(displayName.trim(), "", shortDescr.trim().ifEmpty { null }, image = image) - val newUser = controller.apiCreateProfileKeepingActive(rhId, profile) ?: return@withBGApi + val newUser = controller.apiCreateProfileKeepingActive(rhId, profile) ?: return@withApi if (newUser.activeUser) { - // The core did not honour keepActiveUser and activated the profile - an older - // remote host ignoring the unknown field. Reassigning would now fail, so resync - // to what the host actually did and report it. Not switching_profile_error_message: - // that says the invitation was moved, and on this path it was not. - // The form is dismissed first: the app is about to be showing a different - // profile, and leaving the form on top of it invites a second attempt that - // would fail the same way. - withContext(Dispatchers.Main) { - if (modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) close() - controller.changeActiveUser(newUser.remoteHostId, newUser.userId, null) - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user)) - } - return@withBGApi + // 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() + controller.changeActiveUser(newUser.remoteHostId, newUser.userId, null) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user)) + return@withApi } - // Keep chatModel.users current even if onCreated's reassignment fails - it only - // refreshes when it actually switches. listUsers throws and withBGApi does not - // catch, so this cosmetic refresh is guarded; and it is applied on the main - // thread, where the receiver loop also updates this list. + // List the new profile even if the reassignment below fails. listUsers throws and + // withApi does not catch, so the cosmetic refresh is guarded. if (chatModel.remoteHostId() == rhId) { - runCatching { controller.listUsers(rhId) }.getOrNull()?.let { updatedUsers -> - withContext(Dispatchers.Main) { - chatModel.users.clear() - chatModel.users.addAll(updatedUsers) - } + runCatching { controller.listUsers(rhId) }.getOrNull()?.let { + chatModel.users.clear() + chatModel.users.addAll(it) } } - // Hand over on the main thread: onCreated reassigns the chat or connection and - // switches the user, all of which update structures the receiver loop also - // writes to on Main - and Main is where both pickers ran this before it became - // a suspending callback. close() touches the modal stack, so it goes here too. - withContext(Dispatchers.Main) { - // A notification tap or a remote host switch can change the active user or the - // host while the profile is being created; reassigning then would resolve the - // invitation under the wrong profile. Stop, with the profile created. - if (chatModel.currentUser.value?.userId != ownerUserId || chatModel.remoteHostId() != rhId) { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user)) - return@withContext - } - // The form is gone - most likely the user backed out of it while the profile - // was being created. Don't move the invitation under a screen they have left, - // and don't report an error for something they did deliberately. - if (!modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) { - Log.i(TAG, "createProfileForInvitation: form closed before the invitation was moved, profile ${newUser.userId} left created") - return@withContext - } - close() - onCreated(newUser) + // 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) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user)) + return@withApi } + // Form gone - the user backed out of it. Don't move the invitation under a screen + // they left, and don't report an error for something they did on purpose. + if (!modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) { + Log.i(TAG, "createProfileForInvitation: form closed before the invitation moved") + return@withApi + } + close() + onCreated(newUser) } finally { chatModel.creatingProfileForInvitation.value = false } 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 1d533492e8..e5b9c69417 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 @@ -42,10 +42,9 @@ fun ComposeContextProfilePickerView( val incognitoDefault = chatModel.controller.appPrefs.incognito.get() val users = chatModel.users.map { it.user }.filter { u -> u.activeUser || !u.hidden } val listExpanded = remember { mutableStateOf(false) } - // Not rememberSaveable: process death would skip the resetting finally and strand it true. + // Not rememberSaveable: process death would skip the resetting finally and strand it true val changingProfile = remember { mutableStateOf(false) } - // Creating a profile keeps the rows disabled too - the reassignment runs after the form - // closes, and until it has, picking anything else moves the invitation twice. + // Creating counts as busy until the invitation has moved, which is after the form closes val busy = changingProfile.value || chatModel.creatingProfileForInvitation.value val maxHeightInPx = with(LocalDensity.current) { windowHeight().toPx() } @@ -107,9 +106,8 @@ fun ComposeContextProfilePickerView( chatMoved = true } } - // Only switch profile if the chat was actually moved to it, otherwise the user - // would end up in another profile with the invitation left behind in this one. - // apiChangePreparedContactUser/apiChangePreparedGroupUser report the failure. + // 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, @@ -117,10 +115,6 @@ fun ComposeContextProfilePickerView( viewPwd = null, keepingChatId = chat.id ) - // No chatId assignment here. changeActiveUser_ passes keepingChatId, and - // updateChats only clears chatId when the chat is missing from the reloaded - // list - so on success this would be a no-op, and in the one case the guard - // does fire it would re-point the chat view at a chat that is not there. if (chatModel.currentUser.value?.userId != newUser.userId) { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.switching_profile_error_title), @@ -134,8 +128,7 @@ fun ComposeContextProfilePickerView( } fun changeProfile(newUser: User) { - // Also set here, not only in changeProfileTo: withApi dispatches, so between the tap - // and the coroutine starting the row would still be enabled. + // Also here, not just in changeProfileTo: withApi dispatches, leaving a gap after the tap changingProfile.value = true withApi { changeProfileTo(newUser) } } @@ -257,8 +250,7 @@ fun ComposeContextProfilePickerView( .fillMaxWidth() .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp) .clickable(enabled = !busy, onClick = { - // Same guard as every other row: the flag is live state the receiver loop - // flips, so it can turn true between this row being laid out and the tap. + // Live state the receiver loop flips, so it can turn true after the row was laid out if (!chat.chatInfo.profileChangeProhibited) { createProfileForInvitation(rhId) { changeProfileTo(it) } } else { @@ -294,7 +286,6 @@ fun ComposeContextProfilePickerView( Modifier .heightIn(max = MAX_USER_PICKER_HEIGHT) .background(MaterialTheme.colors.surface) - // The rows are already unclickable while busy; this is the only thing that says so. .alpha(if (busy) 0.6f else 1f), reverseLayout = true, maxHeight = remember { mutableStateOf(MAX_USER_PICKER_HEIGHT) }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 68267e1f54..46b503c9d0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -118,31 +118,18 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id - /** Like [isLastModalOpen], except that a modal already dismissed and only waiting out - * its close animation does not count as open: [closeModal] leaves it in [modalViews] - * and stages its index in [toRemove]. A caller that decides whether to close on "is my - * modal still the last one?" needs this, or a back-tap during a long operation makes it - * pop the screen underneath as well. - * - * Separate from [isLastModalOpen] rather than folded into it, because that one is also - * used to decide when to tear down secondary chats, where the existing behaviour is - * relied on. */ + /** [isLastModalOpen], except that a modal already dismissed and only waiting out its + * close animation does not count - [closeModal] leaves it in [modalViews] and stages + * its index in [toRemove]. A caller deciding whether to close on "is my modal still the + * last one?" needs this, or a back-tap during a long operation pops the screen beneath + * as well. Kept separate because [isLastModalOpen] also gates secondary chat teardown, + * which relies on the existing behaviour. */ fun isLastModalOpenNotClosing(id: ModalViewId): Boolean { var i = modalViews.size - 1 while (i >= 0 && i in toRemove) i-- return i >= 0 && modalViews.getOrNull(i)?.id == id } - /** [hasModalOpen] with the same exclusion as [isLastModalOpenNotClosing]: a modal - * dismissed but still animating out does not count. Without it, re-opening something - * straight after closing it is a silent no-op for the length of the animation. */ - fun hasModalOpenNotClosing(id: ModalViewId): Boolean { - for (i in modalViews.indices) { - if (i !in toRemove && modalViews.getOrNull(i)?.id == id) return true - } - return false - } - fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, cardScreen: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { showCustomModal(id = id, forceAnimated = forceAnimated) { close -> ModalView(close, showClose = showClose, cardScreen = cardScreen, endButtons = endButtons, content = { content() }) 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 163031c948..ffb7f16faf 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 @@ -300,16 +300,14 @@ fun ActiveProfilePicker( 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 profile count as well so a profile created from this picker appears in it - // without relying on the composition being torn down and rebuilt by the form on top. + // Keyed on the users too, or a profile created from this picker is missing from it. val filteredProfiles = remember(searchTextOrPassword.value, chatModel.users.size, chatModel.currentUser.value?.userId) { filteredProfiles(chatModel.users.map { it.user }.sortedBy { !it.activeUser }, searchTextOrPassword.value) } var progressByTimeout by rememberSaveable { mutableStateOf(false) } - // Creating a profile for this invitation keeps the picker busy until the connection has - // been moved onto it, not just until the profile exists. + // Busy until the connection has moved onto the new profile, not just until it exists val busy = switchingProfile.value || chatModel.creatingProfileForInvitation.value LaunchedEffect(busy) { @@ -320,9 +318,8 @@ fun ActiveProfilePicker( false } } - // Creating skips the 500ms grace: the picker is recomposed from scratch when the form - // closes, so progressByTimeout starts again from false and would leave the rows looking - // idle while the invitation is being moved. + // Creating skips the 500ms grace: the picker is recomposed when the form closes, so + // progressByTimeout restarts from false and the rows would look idle val showProgress = progressByTimeout || chatModel.creatingProfileForInvitation.value suspend fun selectProfileAsync(user: User) { @@ -332,20 +329,15 @@ fun ActiveProfilePicker( if (contactConnection != null) { updatedConn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId) - // The connection was not moved. Leave the picker open instead of switching or - // dismissing: a profile just created for this invitation would otherwise be - // stranded with nothing pointing at it, and this call provisions a new queue, so - // it is what fails offline. apiChangeConnectionUser reports the failure itself - // except when sendCmdWithRetry gives up, which is either the user cancelling the - // retry or the command being cancelled. + // 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 withContext(Dispatchers.Main) { chatModel.chatsContext.updateContactConnection(rhId, updatedConn) updateShownConnection(updatedConn) } } - // Set only once the connection is known to have moved, so a failed reassignment - // does not silently turn the app-wide incognito default off. + // Only once the connection has moved, or a failure silently clears the app-wide default appPreferences.incognito.set(false) controller.changeActiveUser_( @@ -367,9 +359,8 @@ fun ActiveProfilePicker( } } - // Only if this picker is still the top of its stack. The reassignment above can - // wait indefinitely on the retry alert, and back is not blocked while it does, so - // an unconditional close() here would pop whatever the user moved on to. + // Only if still the top: the call above can wait indefinitely on the retry alert + // while back stays enabled, and an unconditional close() would pop the wrong screen. if (ModalManager.start.isLastModalOpenNotClosing(ModalViewId.ACTIVE_PROFILE_PICKER)) { close() }