Files
simplex-chat/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift
T
Narasimha-sc b9eb86541c ios: don't capture a mutable local in a @Sendable closure
MainActor.run's body is @Sendable, and the switched flag added in the previous commit
was a var captured by it - which strict concurrency diagnoses. Assigning a let on both
branches of the do/catch is definitely initialised and safe to capture anywhere, with
no behaviour change.
2026-08-06 16:12:26 +00:00

444 lines
19 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()
}
}
// 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 {
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 }
// Descending, as every other profile list sorts: a profile
// that was never activated has active_order 0 and belongs
// at the end, not the front.
.sorted(using: KeyPathComparator<User>(\.activeOrder, order: .reverse))
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 {
incognitoDefault = false
listExpanded = false
}
} else if selectedUser != user {
// Only the branch that starts work is guarded - expanding and
// collapsing the list is local and stays available.
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 {
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)
}
// Creates a profile to use for this invitation. It is created without becoming
// active, because changeProfile below reassigns the prepared chat and the API
// resolves that chat under the currently active user - so the profile that owns the
// invitation has to stay active until the chat has been moved.
private func createProfileForChat(_ displayName: String, _ shortDescr: String?, _ image: String?) async throws {
// Atomic check-and-set on the main actor: a plain check-then-set leaves a window
// where two submits both pass, 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 profile = Profile(displayName: displayName, fullName: "", shortDescr: shortDescr, image: image)
let newUser = try apiCreateProfileKeepingActive(profile)
let updatedUsers = try? listUsers()
await MainActor.run {
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. The failure is the
// switch, not the creation, so it is not rethrown into the form's "error
// creating profile" handler.
// let, not var: MainActor.run's body is @Sendable, and capturing a mutable
// local in one is diagnosed under strict concurrency. Assigned on both
// branches, so it is definitely initialised.
let switched: Bool
do {
try await changeActiveUserAsync_(newUser.userId, viewPwd: nil)
switched = true
} catch {
logger.error("changeActiveUserAsync_ error: \(responseError(error))")
switched = false
}
await MainActor.run {
// Only when the switch actually happened: the prepared chat then belongs
// to a profile that is no longer active, so it is absent from the reloaded
// list and a pushed chat view renders blank. If the switch failed nothing
// moved and the chat is still fine - closing it would be the regression.
if switched && chatModel.chatId == chat.id { chatModel.chatId = nil }
}
// The switch replaces the chat list, which removes this view - and the sheet
// it presents - from the hierarchy. Both that teardown and an explicit
// dismissal animate, and getTopViewController() keeps returning the sheet
// until the transition ends, so an alert raised now is presented on a
// controller being dismissed and dropped. Let it settle first.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
showAlert(NSLocalizedString("Error changing chat profile", comment: "alert title"))
}
return
}
// 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)
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)
// 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 {
showAlert(
NSLocalizedString("Error switching profile", comment: "alert title"),
message: 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
}
showAlert(
NSLocalizedString("Error changing chat profile", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
private func incognitoOption() -> some View {
Button {
if !chat.chatInfo.profileChangeProhibited {
if incognitoDefault {
listExpanded.toggle()
} else {
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
)
}