report the covered-form case, and stop gating local expand/collapse

The form being covered by another modal was the only branch here that said
nothing: the profile exists, the invitation did not move, and the form comes
back with the name still typed so Create fails as a duplicate. Add
isModalOpenNotClosing to tell covered from dismissed - excluding toRemove, or
the close animation would report a dismissal as a cover.

Gate the compose picker's branches rather than its rows. Master had no gate
there at all, so blocking the local expand/collapse was a regression this
branch introduced; only changeProfile and the incognito writes need it.
This commit is contained in:
Narasimha-sc
2026-08-07 22:06:40 +00:00
parent 077af35e19
commit 2168851adb
4 changed files with 49 additions and 12 deletions
@@ -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()
@@ -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
}
@@ -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. */
+23 -4
View File
@@ -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.