diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift index 98afa31a47..7c39b8e59b 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift @@ -256,6 +256,7 @@ struct ContextProfilePickerView: View { } if alreadyCreating { return } defer { Task { @MainActor in creatingProfile = false } } + 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 @@ -281,6 +282,13 @@ struct ContextProfilePickerView: View { alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title")) return } + // changeProfile resolves the prepared chat under whatever is active when it runs, + // and a notification action can have switched it while we were creating. + guard await MainActor.run({ chatModel.currentUser?.userId }) == ownerUserId else { + await MainActor.run { showAddProfile = false } + alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title")) + return + } let updatedUsers = try? await listUsersAsync() await MainActor.run { if let updatedUsers = updatedUsers { diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index ff04578764..9e0d9f9ebc 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -592,6 +592,7 @@ private struct ActiveProfilePicker: View { } if alreadyCreating { return } defer { Task { @MainActor in creatingProfile = false } } + 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 @@ -617,6 +618,13 @@ private struct ActiveProfilePicker: View { alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title")) return } + // apiChangeConnectionUser resolves pccConnId under whatever is active when the + // selectedProfile handler runs, and a notification action can have switched it. + guard await MainActor.run({ chatModel.currentUser?.userId }) == ownerUserId else { + await MainActor.run { showAddProfile = false } + alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title")) + return + } let updatedUsers = try? await listUsersAsync() await MainActor.run { if let updatedUsers = updatedUsers { chatModel.users = updatedUsers } 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 70c5135233..10caf342e4 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 @@ -401,6 +401,9 @@ fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) { // 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 } 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 fff96a1df0..36bd5d4082 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 @@ -41,9 +41,10 @@ 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) } - // Set until the invitation has moved onto the new profile, which is after the form has - // closed and this picker is interactive again - val busy = chatModel.creatingProfileForInvitation.value + 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() } val isVisible = remember { mutableStateOf(false) } @@ -121,7 +122,12 @@ fun ComposeContextProfilePickerView( } fun changeProfile(newUser: User) { - withApi { changeProfileTo(newUser) } + // 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 } + } } fun showCantChangeProfileAlert() { diff --git a/plans/2026-07-30-new-profile-for-invitation.md b/plans/2026-07-30-new-profile-for-invitation.md index 2bc3b1f6e7..3c98479662 100644 --- a/plans/2026-07-30-new-profile-for-invitation.md +++ b/plans/2026-07-30-new-profile-for-invitation.md @@ -180,6 +180,19 @@ Uses the existing `users_add` ("Add profile") string — **zero new translation new profile always has the SimpleX Team/Status cards. Core returns the updated contact; do not pre-check. +Two hazards left as they are on master, so review does not keep re-raising them: + +- `selectProfileAsync`'s trailing `close()` pops whatever is on top rather than the + picker, so backing out during the connection change dismisses the screen underneath. + The ordinary row tap has had exactly that window since before this branch; creating a + profile first is local and fast and barely widens it. Fixing it needs a `ModalViewId` + on the picker at both call sites, including `ShareListView`, which does not offer this + feature at all. +- `alertAfterDismissal` waits a fixed 0.5s for a sheet transition rather than observing + it. A slow device or a late-released interactive dismissal can still outlast it. The + deterministic version needs the presenting controller's completion handler, which is + not reachable from where these alerts are raised. + ## 5. Generated and hand-synced artifacts — easy to miss `NewUser` is a documented API type, and `apiDocsTest` generates **11 files** from those