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 34a81d2548..d2224ea022 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 @@ -426,10 +426,19 @@ fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) { 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. + // 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)) { - Log.i(TAG, "createProfileForInvitation: form closed before the invitation moved") + 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 } close() 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 36bd5d4082..ea77b36f79 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 @@ -143,16 +143,19 @@ fun ComposeContextProfilePickerView( Modifier .fillMaxWidth() .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp) - .clickable(enabled = !busy, onClick = { + // 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) { if (!incognitoDefault) { listExpanded.value = !listExpanded.value - } else { + } else if (!busy) { chatModel.controller.appPrefs.incognito.set(false) listExpanded.value = false } - } else { + } else if (!busy) { changeProfile(user) } } else { @@ -186,11 +189,11 @@ fun ComposeContextProfilePickerView( Modifier .fillMaxWidth() .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp) - .clickable(enabled = !busy, onClick = { + .clickable(onClick = { if (!chat.chatInfo.profileChangeProhibited) { if (incognitoDefault) { listExpanded.value = !listExpanded.value - } else { + } else if (!busy) { chatModel.controller.appPrefs.incognito.set(true) listExpanded.value = false } 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 5c8769ec81..8fd9b3b40a 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 @@ -117,6 +117,12 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id + /** [hasModalOpen], but a modal already dismissed and only waiting out its close animation + * does not count. Together with [isLastModalOpenNotClosing] this separates "covered by + * another modal" from "dismissed", which the last-position test alone cannot. */ + fun isModalOpenNotClosing(id: ModalViewId): Boolean = + modalViews.withIndex().any { (i, m) -> m.id == id && i !in toRemove } + /** [isLastModalOpen], but a modal already dismissed and only waiting out its close * animation does not count - [closeModal] leaves it in [modalViews] until then. Separate * from [isLastModalOpen], which gates secondary chat teardown on the existing behaviour. */ diff --git a/plans/2026-07-30-new-profile-for-invitation.md b/plans/2026-07-30-new-profile-for-invitation.md index 3b5d55fd31..0b50cb916c 100644 --- a/plans/2026-07-30-new-profile-for-invitation.md +++ b/plans/2026-07-30-new-profile-for-invitation.md @@ -379,7 +379,15 @@ re-derive them. The migration screens have the same fall-through, but they are not layered over a screen with its own handler. The separate case below is still open. -- **A modal pushed on top of the create form is indistinguishable from backing out of it** +- ~~**A modal pushed on top of the create form is indistinguishable from backing out of it**~~ + **Fixed.** `isModalOpenNotClosing` was added beside `isLastModalOpenNotClosing`, so the + two cases now separate: still present but not on top means covered — report it, because + the form returns with the name still typed and Create would fail as a duplicate; absent + means dismissed — stay quiet, the user chose that. Neither case moves the invitation, and + neither calls `close()`, which would still pop the covering modal. Original note kept + below for the reasoning about why proceeding is not an option: + +- **(superseded) A modal pushed on top of the create form is indistinguishable from backing out of it** (`WelcomeView.kt`). The guard is `!isLastModalOpenNotClosing(...)`, which is also false when another modal covers the form — on Android all four `ModalManager` placements share one stack, so a deep link or notification action arriving mid-creation makes it fire: @@ -420,9 +428,11 @@ Three more, from a later round: The justification originally given — that holding it still avoids swapping `profilePicker()` for `currentSelection()` mid-flight — was borrowed from iOS, where that swap disposes a presented sheet; Kotlin has no sheet to lose, so it does not apply here. - Left as is only because the change is cosmetic and touching these rows again is the kind - of edit that has repeatedly introduced defects in this branch. Gate the work-starting - branches instead, as iOS does, if this picker is revisited. + **Fixed** by gating the branches rather than the row, as iOS does. Only `changeProfile` + starts async work; the two `incognito.set` branches change state and are gated too; + expanding and collapsing is local and is now always available. This was a regression + introduced by this branch — master had no gate on these rows at all — which is what + finally settled it, after being raised three times and deferred twice. - **`submitting:` and `.interactiveDismissDisabled(...)` are read inside the `.sheet` content closure.** If SwiftUI does not re-invoke that closure when the presenting view's @@ -528,3 +538,12 @@ user just dismissed — the mistake fixed once already in this branch, where What *is* true: pressing "Add profile" a second time with the same name fails as a duplicate. That is the core refusing to create a profile that already exists, and the fix is to tap the row rather than recreate it. + +One consequence of the registration placement (§8), recorded so it is a choice and not an +oversight: on the stale-core path — an old remote host activated the profile despite +`keepActiveUser` — the profile is **not** added to `chatModel.users` if the resync then +throws. It is registered below that branch precisely because registering above it left two +entries flagged `activeUser`, which makes `firstOrNull { it.activeUser }` resolve to +whichever comes first. Corrupting which profile reads as active is worse than a missing +row, and the missing row needs an outdated remote host *and* a failed resync to appear at +all; the alert on that path tells the user something went wrong either way.