diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift index 734482f900..9ac3b03141 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift @@ -23,6 +23,7 @@ struct ContextProfilePickerView: View { @State private var showIncognitoSheet = false @State private var showAddProfile = false @State private var creatingProfile = false + @State private var changingProfile = false @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @@ -166,8 +167,11 @@ struct ContextProfilePickerView: View { } } + private var busy: Bool { creatingProfile || changingProfile } + private func profilerPickerUserOption(_ user: User) -> some View { Button { + if busy { return } if !chat.chatInfo.profileChangeProhibited { if selectedUser == user { if !incognitoDefault { @@ -235,7 +239,7 @@ struct ContextProfilePickerView: View { .padding(.trailing) .frame(height: USER_ROW_SIZE) } - .disabled(creatingProfile) + .disabled(busy) } // Creates a profile to use for this invitation. It is created without becoming @@ -271,25 +275,43 @@ struct ContextProfilePickerView: View { // 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. + // + // Dismiss the form first. The switch below replaces the chat list with the new + // user's, and the prepared chat stayed with the previous one - so this view, + // which is the sheet's presenter, is removed from the hierarchy by the switch + // itself. Nothing shown from inside it would survive, and an alert raised + // while it is being torn down is discarded. + await MainActor.run { showAddProfile = false } 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. + } catch { + logger.error("changeActiveUserAsync_ error: \(responseError(error))") + } await MainActor.run { + // The prepared chat is not in the new user's list, so a pushed chat view + // would render blank. Only clear it if it is still the chat this picker + // belongs to - a notification tap may have navigated elsewhere by now. + if chatModel.chatId == chat.id { chatModel.chatId = nil } showAlert(NSLocalizedString("Error changing chat profile", comment: "alert title")) } return } - await MainActor.run { showAddProfile = false } + // changingProfile set here, not only inside changeProfile's Task: the defer above + // clears creatingProfile as soon as this function returns, and that Task has not + // necessarily started by then - the rows would be live in between. + await MainActor.run { + showAddProfile = false + changingProfile = true + } changeProfile(newUser) } private func changeProfile(_ newUser: User) { Task { + // Two round trips follow; without this every row, including "Add profile", + // stays live and a second change can be started on top of this one. + await MainActor.run { changingProfile = true } + defer { Task { @MainActor in changingProfile = false } } do { if let contact = chat.chatInfo.contact { let updatedContact = try await apiChangePreparedContactUser(contactId: contact.contactId, newUserId: newUser.userId) @@ -339,6 +361,7 @@ struct ContextProfilePickerView: View { private func incognitoOption() -> some View { Button { + if busy { return } if !chat.chatInfo.profileChangeProhibited { if incognitoDefault { listExpanded.toggle() diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index fab3b56b4d..a31bd3ec14 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -427,12 +427,20 @@ private struct ActiveProfilePicker: View { profileSwitchStatus = .idle dismiss() } + } else { + // Same latch as in the selectedProfile handler below: without + // this, a nil result leaves profileSwitchStatus stuck and the + // picker dimmed behind a spinner with hit testing off. + await MainActor.run { + profileSwitchStatus = .idle + incognitoEnabled = !incognito + } } } catch { - profileSwitchStatus = .idle - incognitoEnabled = !incognito logger.error("apiSetConnectionIncognito error: \(responseError(error))") await MainActor.run { + profileSwitchStatus = .idle + incognitoEnabled = !incognito showErrorAlert(error, NSLocalizedString("Error changing to incognito!", comment: "")) } } @@ -481,10 +489,18 @@ private struct ActiveProfilePicker: View { } } else { // No connection, or apiChangeConnectionUser returned nothing: - // nothing was moved, so don't switch or dismiss - and reset the + // nothing was moved, so don't switch or dismiss. Reset the // status, which otherwise latches the picker into its spinner - // with hit testing off. - await MainActor.run { profileSwitchStatus = .idle } + // 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. + await MainActor.run { + profileSwitchStatus = .idle + if let currentUser = chatModel.currentUser { + selectedProfile = currentUser + } + } } } catch { await MainActor.run { @@ -619,6 +635,12 @@ private struct ActiveProfilePicker: View { // controller, so it appears over the form, which stays open as on any other // failure. await MainActor.run { + // The app has switched to the new profile, so make the picker agree with + // it rather than leaving the checkmark on a profile that is no longer + // active. The connection stayed with the previous profile and cannot be + // moved from here; the alert says so. + profileSwitchStatus = .idle + selectedProfile = newUser showAlert(NSLocalizedString("Error changing chat profile", comment: "alert title")) } return @@ -692,9 +714,8 @@ private struct ActiveProfilePicker: View { } .opacity(switchingProfileByTimeout ? 0.4 : 1) // 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. + // may dispose it, taking the presented sheet with it. Unlike the compose picker + // this view is never replaced, so the picker is a stable enough owner. .sheet(isPresented: $showAddProfile) { NavigationView { CreateProfile(onSubmit: { displayName, shortDescr, image in