Files
simplex-chat/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift
T
Narasimha-sc 02eaa882ac fix the docs the API merge invalidated, and use the async listUsers
Merging keepActiveUser into apiCreateActiveUser changed a signature that two Kotlin
docs quote. spec/api.md still described the old parameters and said the command sets
the profile as active, full stop; product/flows/onboarding.md quoted the old signature
one step above the line this branch had already updated to mention the flag. The iOS
spec table was fixed in the previous commit and these were missed - the same drift on
the other platform.

Both iOS call sites now use listUsersAsync, which is what changeActiveUserAsync_ uses
a few lines later; the synchronous listUsers blocks whatever executor the Task is on,
and SwiftUI infers that as the main actor.

Not compiled: the Kotlin changes here are markdown only, and the Swift ones cannot be
built on this machine.
2026-08-06 17:49:37 +00:00

418 lines
17 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, not the row or profilePicker(): both are disposed while this is
// presented - the row by its lazy container, the picker when listExpanded flips.
// Stacking with body's IncognitoHelp sheet is fine from iOS 14.5; target is 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 }
.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 {
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)
}
// 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 apiCreateActiveUser(profile, keepActiveUser: true)
let updatedUsers = try? await listUsersAsync()
await MainActor.run {
if let updatedUsers = updatedUsers {
chatModel.users = updatedUsers
// Otherwise only filled in onAppear, so the profile just created is
// missing from the picker, and the row count the frame uses is one short.
users = updatedUsers.map { $0.user }.filter { u in u.activeUser || !u.hidden }
}
}
if newUser.activeUser {
// An older remote host ignored keepActiveUser and activated it, so the
// reassignment would fail. Resync to what the host did and report it - not
// rethrown, or the form reports it as a failure to create the profile.
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 {
// Dismissed unconditionally: the switch removes this view - and the sheet
// it presents - only when it succeeded. If it threw, the form would be
// left over a profile that already exists.
showAddProfile = false
// Only if the switch happened: the chat is then absent from the reloaded
// list and would render blank. If it failed, the chat is still fine.
if switched && chatModel.chatId == chat.id { chatModel.chatId = nil }
}
alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title"))
return
}
// changingProfile here too: the defer above clears creatingProfile as soon as this
// returns, and changeProfile's Task has not necessarily started by then.
await MainActor.run {
showAddProfile = false
changingProfile = true
}
changeProfile(newUser)
}
private func changeProfile(_ newUser: User) {
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 {
alertAfterDismissal(
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
}
}
alertAfterDismissal(
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 {
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
)
}