Files
simplex-chat/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift
T
Narasimha-sc 0853b5919c core, ui: strip PR comments, fix Android profile-for-invitation issues
- remove all comments introduced by this PR across the touched files,
  per review decision
- fix the "New Profile" row being hidden behind the bottom app bar in
  one-handed UI mode once the profile list grows long enough to reach it
- stop force-setting connChatUsed on reassignment, so the "keep unused
  invitation?" prompt correctly fires for a genuinely unused invitation
- fix NewChatView's contactConnection state going stale after a
  reassignment (it never reflected the new connection), causing both
  the keep/delete decision and any later picker reopen to reference an
  already-superseded connection
2026-09-01 17:29:28 +00:00

433 lines
16 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
private enum ContextProfilePickerSheet: Identifiable, Hashable {
case incognitoHelp
case addProfile
var id: Self { self }
}
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 activeSheet: ContextProfilePickerSheet? = nil
@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 }
}
}
private func viewBody() -> some View {
Group {
if !listExpanded || chat.chatInfo.profileChangeProhibited {
currentSelection()
} else {
profilePicker()
}
}
.sheet(item: $activeSheet) { sheet in
switch sheet {
case .incognitoHelp:
IncognitoHelp()
case .addProfile:
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)))
.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 {
incognitoDefault = false
listExpanded = false
}
} else if selectedUser != user {
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 {
activeSheet = .addProfile
}
} 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)
}
private func createProfileForChat(_ displayName: String, _ shortDescr: String?, _ image: String?) async throws {
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)
if newUser.activeUser {
do {
try await changeActiveUserAsync_(newUser.userId, viewPwd: nil)
} catch {
logger.error("changeActiveUserAsync_ error: \(responseError(error))")
}
await MainActor.run {
activeSheet = nil
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)
}
if chatModel.currentUser?.userId == newUser.userId && chatModel.chatId == chat.id {
chatModel.chatId = nil
}
}
alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title"))
return
}
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()
let activeUserId = await MainActor.run { chatModel.currentUser?.userId }
guard activeUserId == ownerUserId else {
await MainActor.run { activeSheet = nil }
alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title"))
return
}
await MainActor.run {
if let updatedUsers = updatedUsers {
chatModel.users = updatedUsers
users = updatedUsers.map { $0.user }.filter { u in u.activeUser || !u.hidden }
}
activeSheet = nil
changingProfile = true
}
changeProfile(newUser, dismissingSheet: true)
}
private func changeProfile(_ newUser: User, dismissingSheet: Bool = false) {
func report(_ title: String, _ message: String? = nil) {
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)
await MainActor.run {
if let chatId = chatModel.chatId, chatModel.getChat(chatId) == nil {
chatModel.chatId = nil
}
}
} 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 {
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 {
activeSheet = .incognitoHelp
}
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
)
}