ios: fix the create-profile-for-invitation flow

Mirrors the Android/desktop fixes where iOS has the same defects, and fixes the ones
that are specific to SwiftUI. Still not compiled - no Swift toolchain here, same
status as the rest of the iOS port in this branch.

Swallowed alert. The one-time link picker reported "the core ignored keepActiveUser"
by setting its own `alert` @State, consumed by .alert(item:) on the very view that is
presenting the create-profile sheet. UIKit refuses an alert on a controller that
already has a presentation, so it was never shown: the active profile silently
switched, the connection stayed where it was, and the user was left looking at an
unchanged form. Uses the global showAlert, which targets the top view controller, as
the compose picker already does.

Stale picker list. The compose picker fills its own `users` in onAppear and never
again, so the profile just created was missing from the list whenever the
reassignment failed - tapping "Add profile" again then hit "Duplicate display name!"
- and the frame height, which is computed from that count, was one row short. The
local from listUsers also shadowed the property, which is what hid this.

Resync failure reported as a creation failure. changeActiveUserAsync_ throws, and on
the "core ignored the flag" path that propagated into the form's catch, which says
"Error creating profile!" although the profile had been created. On the compose
picker that path also left chatId pointing at a prepared chat that had stayed with
the previous profile, i.e. at a chat absent from the list, which renders blank.

Sheet owner destroyed mid-dismissal. The compose picker's sheet was attached inside
profilePicker(), which is swapped out for currentSelection() the moment listExpanded
flips - which changeProfile does while the sheet is still dismissing, and which an
incoming event can do at any time via profileChangeProhibited. Moved to the Group in
viewBody(), which survives both; stacking sheets is supported from iOS 14.5 and the
app targets 15.

Dismissal during creation. The submit runs in an unstructured Task that SwiftUI does
not cancel, so a swipe-to-dismiss still created the profile and switched to it while
every state write, including the one clearing the in-flight flag, landed on a view
that was gone. Both sheets now block interactive dismissal while creating.

Latched picker. If apiChangeConnectionUser returns nothing rather than throwing,
profileSwitchStatus was never reset, leaving the picker dimmed behind a spinner with
hit testing off. Pre-existing, but the new row routes through the same handler.

Re-tappable row. switchingProfileByTimeout only latches half a second after the
switch starts, and creatingProfile is cleared as soon as the switch is handed over,
so the row was live in between.
This commit is contained in:
Narasimha-sc
2026-08-06 13:45:11 +00:00
parent f2f5ab3892
commit 4a169a3061
2 changed files with 67 additions and 30 deletions
@@ -46,6 +46,20 @@ 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.
.sheet(isPresented: $showAddProfile) {
NavigationView {
CreateProfile(onSubmit: { displayName, shortDescr, image in
try await createProfileForChat(displayName, shortDescr, image)
})
}
.interactiveDismissDisabled(creatingProfile)
}
}
private func currentSelection() -> some View {
@@ -150,19 +164,8 @@ struct ContextProfilePickerView: View {
}
}
}
// 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. Not the root either - two
// .sheet modifiers on the same view conflict, and body already presents
// IncognitoHelp.
.sheet(isPresented: $showAddProfile) {
NavigationView {
CreateProfile(onSubmit: { displayName, shortDescr, image in
try await createProfileForChat(displayName, shortDescr, image)
})
}
}
}
private func profilerPickerUserOption(_ user: User) -> some View {
Button {
if !chat.chatInfo.profileChangeProhibited {
@@ -251,17 +254,31 @@ struct ContextProfilePickerView: View {
defer { Task { @MainActor in creatingProfile = false } }
let profile = Profile(displayName: displayName, fullName: "", shortDescr: shortDescr, image: image)
let newUser = try apiCreateProfileKeepingActive(profile)
let users = try? listUsers()
let updatedUsers = try? listUsers()
await MainActor.run {
if let users = users { chatModel.users = users }
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.
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.
try await changeActiveUserAsync_(newUser.userId, viewPwd: nil)
// The form stays open, as it does for any other failure, so the alert is not
// presented while a sheet is dismissing - it would be swallowed.
// 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.
do {
try await changeActiveUserAsync_(newUser.userId, viewPwd: nil)
// The prepared chat stayed with the previous profile and is not in this
// one's list, so leave it rather than showing an empty chat view.
await MainActor.run { chatModel.chatId = nil }
} catch {}
// The form stays open, as it does for any other failure. showAlert presents
// through UIKit on the top view controller, so it is shown over the sheet.
await MainActor.run {
showAlert(NSLocalizedString("Error changing chat profile", comment: "alert title"))
}
@@ -293,9 +310,10 @@ struct ContextProfilePickerView: View {
}
do {
try await changeActiveUserAsync_(newUser.userId, viewPwd: nil, keepingChatId: chat.id)
// Reopen the chat under the new profile: keepingChatId only preserves
// its place in the reloaded list, so without this the switch lands on
// the chat list rather than the invitation it was chosen for.
// Assert the open chat: nothing on this path clears chatId on iOS, so
// this is normally a no-op, but keepingChatId only keeps the chat's
// place in the reloaded list - it does not open it. The id is
// unchanged by the reassignment, it is the contact/group id.
await MainActor.run { chatModel.chatId = chat.id }
} catch {
await MainActor.run {
@@ -479,6 +479,12 @@ private struct ActiveProfilePicker: View {
)
}
}
} else {
// No connection, or apiChangeConnectionUser returned nothing:
// nothing was moved, so don't switch or dismiss - and reset the
// status, which otherwise latches the picker into its spinner
// with hit testing off.
await MainActor.run { profileSwitchStatus = .idle }
}
} catch {
await MainActor.run {
@@ -573,7 +579,10 @@ private struct ActiveProfilePicker: View {
Spacer()
}
}
.disabled(creatingProfile || switchingProfileByTimeout)
// 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(creatingProfile || switchingProfileByTimeout || profileSwitchStatus != .idle)
}
// Creates a profile for this invitation without activating it, then routes through
@@ -591,20 +600,26 @@ private struct ActiveProfilePicker: View {
defer { Task { @MainActor in creatingProfile = false } }
let profile = Profile(displayName: displayName, fullName: "", shortDescr: shortDescr, image: image)
let newUser = try apiCreateProfileKeepingActive(profile)
let users = try? listUsers()
let updatedUsers = try? listUsers()
await MainActor.run {
if let users = users { chatModel.users = users }
if let updatedUsers = updatedUsers { chatModel.users = updatedUsers }
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.
try await changeActiveUserAsync_(newUser.userId, viewPwd: nil)
// 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.
do {
try await changeActiveUserAsync_(newUser.userId, viewPwd: nil)
} catch {}
// Not the view's own `alert`: it is bound to a view that is presenting this
// form as a sheet, and UIKit refuses an alert on a controller that already
// has one presented - it would never be shown. showAlert goes to the top view
// controller, so it appears over the form, which stays open as on any other
// failure.
await MainActor.run {
alert = SomeAlert(
alert: Alert(title: Text("Error changing chat profile")),
id: "createProfileActivatedError"
)
showAlert(NSLocalizedString("Error changing chat profile", comment: "alert title"))
}
return
}
@@ -686,6 +701,10 @@ private struct ActiveProfilePicker: View {
try await createProfileForConnection(displayName, shortDescr, image)
})
}
// The submit runs in an unstructured Task that SwiftUI does not cancel, so a
// swipe-to-dismiss mid-create would still create the profile and switch to it
// while every state write landed on a dismissed view.
.interactiveDismissDisabled(creatingProfile)
}
}
}