Files
simplex-chat/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift
T
Narasimha-sc a59c37cb9c register the profile in the stale-core branches instead of trusting the resync
changeActiveUser_ writes currentUser inside its mutex before it lists users,
and changeActiveUserAsync_ runs both API calls before the MainActor.run that
assigns m.users - so a throw in between leaves the model pointing at a
profile its own list does not contain. The comment claiming the resync
covers it was wrong on exactly that path.

Also rethrow CancellationException rather than reporting it as a failure,
hop to main before showAlert on the immediate path (it runs inside a Task,
and only alertAfterDismissal was hopping), and put the new row's divider
under it - reverseLayout flips items, not the content of one, so it was
drawing a line along the picker's top edge.
2026-08-07 23:27:34 +00:00

461 lines
20 KiB
Swift

//
// ContextProfilePickerView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 13.06.2025.
// Copyright © 2025 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
let USER_ROW_SIZE: CGFloat = 60
let MAX_VISIBLE_USER_ROWS: CGFloat = 4.8
struct ContextProfilePickerView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@State var selectedUser: User
@State private var users: [User] = []
@State private var listExpanded = false
@State private var expandedListReady = false
@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
var body: some View {
viewBody()
.onAppear {
users = chatModel.users
.map { $0.user }
.filter { u in u.activeUser || !u.hidden }
}
.sheet(isPresented: $showIncognitoSheet) {
IncognitoHelp()
}
}
private func viewBody() -> some View {
Group {
if !listExpanded || chat.chatInfo.profileChangeProhibited {
currentSelection()
} else {
profilePicker()
}
}
// On the Group: the row and profilePicker() are both disposed while this is
// presented. Stacking sheets is supported from iOS 14.5; the target is 15.
.sheet(isPresented: $showAddProfile) {
NavigationView {
CreateProfile(onSubmit: { displayName, shortDescr, image in
try await createProfileForChat(displayName, shortDescr, image)
}, submitting: creatingProfile)
}
.interactiveDismissDisabled(creatingProfile)
}
}
private func currentSelection() -> some View {
VStack(spacing: 0) {
HStack {
Text("Your profile")
.font(.callout)
.foregroundColor(theme.colors.secondary)
Spacer()
}
.padding(.top, 8)
.padding(.bottom, -4)
.padding(.leading, 12)
.padding(.trailing)
if chat.chatInfo.profileChangeProhibited {
if chat.chatInfo.incognito {
incognitoOption()
} else {
profilerPickerUserOption(selectedUser)
}
} else if incognitoDefault {
incognitoOption()
} else {
profilerPickerUserOption(selectedUser)
}
}
}
private func profilePicker() -> some View {
ScrollViewReader { proxy in
Group {
if expandedListReady {
let scroll = ScrollView {
LazyVStack(spacing: 0) {
addProfileOption()
.contentShape(Rectangle())
Divider()
.padding(.leading)
.padding(.leading, 48)
let otherUsers = users
.filter { u in u.userId != selectedUser.userId }
.sorted(using: KeyPathComparator<User>(\.activeOrder))
ForEach(otherUsers) { p in
profilerPickerUserOption(p)
.contentShape(Rectangle())
Divider()
.padding(.leading)
.padding(.leading, 48)
}
if incognitoDefault {
profilerPickerUserOption(selectedUser)
.contentShape(Rectangle())
Divider()
.padding(.leading)
.padding(.leading, 48)
incognitoOption()
.contentShape(Rectangle())
.id("BOTTOM_ANCHOR")
} else {
incognitoOption()
.contentShape(Rectangle())
Divider()
.padding(.leading)
.padding(.leading, 48)
profilerPickerUserOption(selectedUser)
.contentShape(Rectangle())
.id("BOTTOM_ANCHOR")
}
}
}
.frame(maxHeight: USER_ROW_SIZE * min(MAX_VISIBLE_USER_ROWS, CGFloat(users.count + 2))) // + 1 for incognito, + 1 for "Add profile"
.onAppear {
DispatchQueue.main.async {
withAnimation(nil) {
proxy.scrollTo("BOTTOM_ANCHOR", anchor: .bottom)
}
}
}
.onDisappear {
expandedListReady = false
}
if #available(iOS 16.0, *) {
scroll.scrollDismissesKeyboard(.never)
} else {
scroll
}
} else {
// Keep showing current selection to avoid flickering of scroll to bottom
currentSelection()
.onAppear {
// Delay rendering of expanded profile list
DispatchQueue.main.async {
expandedListReady = true
}
}
}
}
}
}
private var busy: Bool { creatingProfile || changingProfile }
private func profilerPickerUserOption(_ user: User) -> some View {
Button {
if !chat.chatInfo.profileChangeProhibited {
if selectedUser == user {
if !incognitoDefault {
listExpanded.toggle()
} else if !busy {
// Gated like the sibling write in incognitoOption, and like both of
// the Kotlin ones: only expand/collapse stays live while busy.
incognitoDefault = false
listExpanded = false
}
} else if selectedUser != user {
// Only the branch that starts work; expand/collapse is local
if busy { return }
changingProfile = true
changeProfile(user)
}
} else {
showCantChangeProfileAlert()
}
} label: {
HStack {
ProfileImage(imageStr: user.image, size: 38)
NameWithBadge(
Text(user.chatViewName)
.fontWeight(selectedUser == user && !incognitoDefault ? .medium : .regular)
.foregroundColor(theme.colors.onBackground),
user.profile.localBadge
)
.lineLimit(1)
Spacer()
if selectedUser == user && !incognitoDefault {
if listExpanded {
Image(systemName: "chevron.down")
.font(.system(size: 12, weight: .bold))
.foregroundColor(theme.colors.secondary)
.opacity(0.7)
} else if !chat.chatInfo.profileChangeProhibited {
Image(systemName: "chevron.up")
.font(.system(size: 12, weight: .bold))
.foregroundColor(theme.colors.secondary)
.opacity(0.7)
}
}
}
.padding(.leading, 12)
.padding(.trailing)
.frame(height: USER_ROW_SIZE)
}
}
private func addProfileOption() -> some View {
Button {
if chat.chatInfo.profileChangeProhibited {
showCantChangeProfileAlert()
} else {
showAddProfile = true
}
} label: {
HStack {
Image(systemName: "person.crop.circle.badge.plus")
.resizable()
.scaledToFit()
.frame(width: 38, height: 38)
.foregroundColor(theme.colors.primary)
Text("Add profile")
.foregroundColor(theme.colors.primary)
.lineLimit(1)
Spacer()
}
.padding(.leading, 12)
.padding(.trailing)
.frame(height: USER_ROW_SIZE)
}
.disabled(busy)
}
// Created without becoming active: changeProfile below resolves the prepared chat
// under the active user, so the profile that owns it must stay active until it moves.
private func createProfileForChat(_ displayName: String, _ shortDescr: String?, _ image: String?) async throws {
// Atomic check-and-set: check-then-set lets two submits through, and @State
// must not be read off the main actor.
let alreadyCreating = await MainActor.run { () -> Bool in
if creatingProfile { return true }
creatingProfile = true
return false
}
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
// activated the new profile, so they would disagree with chatModel.currentUser
// until the resync lands - and changeActiveUserAsync_ refreshes them anyway.
if newUser.activeUser {
// An older remote host ignored keepActiveUser, so the reassignment would
// fail. Resync and report - not rethrown, or the form blames the creation.
do {
try await changeActiveUserAsync_(newUser.userId, viewPwd: nil)
} catch {
logger.error("changeActiveUserAsync_ error: \(responseError(error))")
}
await MainActor.run {
// Unconditional: the switch only removes this view when it succeeded
showAddProfile = false
// Registered here rather than trusting the resync: changeActiveUserAsync_
// calls apiSetActiveUserAsync and listUsersAsync before the MainActor.run
// that writes m.users, so a throw leaves both lists without the profile and
// a retry with the same name is refused as a duplicate.
if !chatModel.users.contains(where: { $0.user.userId == newUser.userId }) {
chatModel.users.append(UserInfo(user: newUser, unreadCount: 0))
}
if !users.contains(where: { $0.userId == newUser.userId }) {
users.append(newUser)
}
// Only if it switched, which the active user tells us: the prepared chat
// is then gone from the reloaded list and would render blank.
if chatModel.currentUser?.userId == newUser.userId && chatModel.chatId == chat.id {
chatModel.chatId = nil
}
}
alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title"))
return
}
// Below the branch above, which returns and registers its own copy: appending here
// as well would leave two entries flagged activeUser. Above the guard below, which
// returns without refreshing either list - the profile exists by now, so it has to
// appear in both or the next attempt at the same name is a duplicate.
// users is otherwise only filled in onAppear.
await MainActor.run {
if !chatModel.users.contains(where: { $0.user.userId == newUser.userId }) {
chatModel.users.append(UserInfo(user: newUser, unreadCount: 0))
}
if !users.contains(where: { $0.userId == newUser.userId }) {
users.append(newUser)
}
}
let updatedUsers = try? await listUsersAsync()
// changeProfile resolves the prepared chat under whatever is active when it runs,
// and a notification action can have switched it while we were creating. After the
// await above, not before it: checked first, that await reopens the very window
// this closes. Kotlin orders it the same way.
guard await MainActor.run({ chatModel.currentUser?.userId }) == ownerUserId else {
await MainActor.run { showAddProfile = false }
alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title"))
return
}
await MainActor.run {
if let updatedUsers = updatedUsers {
chatModel.users = updatedUsers
// Only filled in onAppear otherwise, so the new profile is missing here
users = updatedUsers.map { $0.user }.filter { u in u.activeUser || !u.hidden }
}
// changingProfile here too: the defer clears creatingProfile as soon as this returns
showAddProfile = false
changingProfile = true
}
changeProfile(newUser, dismissingSheet: true)
}
/// [dismissingSheet] only on the create path, which closes the form first: the delay is
/// there to outlast a sheet dismissal, and on the plain row tap there is no sheet, so it
/// would just detach the error from the tap that caused it - master alerted at once.
private func changeProfile(_ newUser: User, dismissingSheet: Bool = false) {
func report(_ title: String, _ message: String? = nil) {
// Both hop to main: this runs inside the Task below, and showAlert presents a
// UIAlertController. alertAfterDismissal does it via asyncAfter, so the
// immediate path has to do it too rather than calling showAlert here.
if dismissingSheet {
alertAfterDismissal(title, message)
} else {
DispatchQueue.main.async { showAlert(title, message: message) }
}
}
Task {
defer { Task { @MainActor in changingProfile = false } }
do {
if let contact = chat.chatInfo.contact {
let updatedContact = try await apiChangePreparedContactUser(contactId: contact.contactId, newUserId: newUser.userId)
await MainActor.run {
selectedUser = newUser
incognitoDefault = false
listExpanded = false
chatModel.updateContact(updatedContact)
}
} else if let groupInfo = chat.chatInfo.groupInfo {
let updatedGroupInfo = try await apiChangePreparedGroupUser(groupId: groupInfo.groupId, newUserId: newUser.userId)
await MainActor.run {
selectedUser = newUser
incognitoDefault = false
listExpanded = false
chatModel.updateGroup(updatedGroupInfo)
}
}
do {
try await changeActiveUserAsync_(newUser.userId, viewPwd: nil, keepingChatId: chat.id)
} catch {
report(
NSLocalizedString("Error switching profile", comment: "alert title"),
String.localizedStringWithFormat(NSLocalizedString("Your chat was moved to %@ but an unexpected error occurred while redirecting you to the profile.", comment: "alert message"), newUser.chatViewName)
)
}
} catch let error {
await MainActor.run {
if let currentUser = chatModel.currentUser {
selectedUser = currentUser
}
}
report(
NSLocalizedString("Error changing chat profile", comment: "alert title"),
responseError(error)
)
}
}
}
private func incognitoOption() -> some View {
Button {
if !chat.chatInfo.profileChangeProhibited {
if incognitoDefault {
listExpanded.toggle()
} else if !busy {
// As in profilerPickerUserOption: a failed changeProfile would leave the
// incognito default on while the picker still shows the chat profile.
// Only this branch - expanding and collapsing is local, as on Kotlin.
incognitoDefault = true
listExpanded = false
}
} else {
showCantChangeProfileAlert()
}
} label : {
HStack {
incognitoProfileImage()
Text("Incognito")
.fontWeight(incognitoDefault ? .medium : .regular)
.foregroundColor(theme.colors.onBackground)
Image(systemName: "info.circle")
.font(.system(size: 16))
.foregroundColor(theme.colors.primary)
.onTapGesture {
showIncognitoSheet = true
}
Spacer()
if incognitoDefault {
if listExpanded {
Image(systemName: "chevron.down")
.font(.system(size: 12, weight: .bold))
.foregroundColor(theme.colors.secondary)
.opacity(0.7)
} else if !chat.chatInfo.profileChangeProhibited {
Image(systemName: "chevron.up")
.font(.system(size: 12, weight: .bold))
.foregroundColor(theme.colors.secondary)
.opacity(0.7)
}
}
}
.padding(.leading, 12)
.padding(.trailing)
.frame(height: USER_ROW_SIZE)
}
}
private func incognitoProfileImage() -> some View {
Image(systemName: "theatermasks.fill")
.resizable()
.scaledToFit()
.frame(width: 38)
.foregroundColor(.indigo)
}
private func showCantChangeProfileAlert() {
showAlert(
NSLocalizedString("Can't change profile", comment: "alert title"),
message: NSLocalizedString("To use another profile after connection attempt, delete the chat and use the link again.", comment: "alert message")
)
}
}
#Preview {
ContextProfilePickerView(
chat: Chat.sampleData,
selectedUser: User.sampleData
)
}