mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-08 23:19:54 +00:00
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.
418 lines
17 KiB
Swift
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
|
|
)
|
|
}
|