// // Created by Avently on 17.01.2023. // Copyright (c) 2023 SimpleX Chat. All rights reserved. // import SwiftUI import SimpleXChat struct UserProfilesView: View { @EnvironmentObject private var m: ChatModel @EnvironmentObject private var theme: AppTheme @Environment(\.editMode) private var editMode @AppStorage(DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE) private var showHiddenProfilesNotice = true @AppStorage(DEFAULT_SHOW_MUTE_PROFILE_ALERT) private var showMuteProfileAlert = true @State private var showDeleteConfirmation = false @State private var userToDelete: User? @State private var alert: UserProfilesAlert? @State private var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) @State private var searchTextOrPassword = "" @State private var selectedUser: User? @State private var profileHidden = false @State private var profileAction: UserProfileAction? @State private var actionPassword = "" var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)} private enum UserProfilesAlert: Identifiable { case deleteUser(user: User, delSMPQueues: Bool) case hiddenProfilesNotice case muteProfileAlert case activateUserError(error: String) case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { switch self { case let .deleteUser(user, delSMPQueues): return "deleteUser \(user.userId) \(delSMPQueues)" case .hiddenProfilesNotice: return "hiddenProfilesNotice" case .muteProfileAlert: return "muteProfileAlert" case let .activateUserError(err): return "activateUserError \(err)" case let .error(title, _): return "error \(title)" } } } private enum UserProfileAction: Identifiable { case deleteUser(user: User, delSMPQueues: Bool) case unhideUser(user: User) var id: String { switch self { case let .deleteUser(user, delSMPQueues): return "deleteUser \(user.userId) \(delSMPQueues)" case let .unhideUser(user): return "unhideUser \(user.userId)" } } } var body: some View { if authorized { userProfilesView() } else { Button(action: runAuth) { Label("Unlock", systemImage: "lock") } .onAppear(perform: runAuth) } } private func runAuth() { authorize(NSLocalizedString("Open user profiles", comment: "authentication reason"), $authorized) } private func userProfilesView() -> some View { List { if profileHidden { Button { withAnimation { profileHidden = false } } label: { Label("Enter password above to show!", systemImage: "lock.open") } } Section { let users = filteredUsers() let v = ForEach(users) { u in userView(u.user) } if #available(iOS 16, *) { v.onDelete { indexSet in if let i = indexSet.first { confirmDeleteUser(users[i].user) } } } else { v } if trimmedSearchTextOrPassword == "" { NavigationLink { CreateProfile() } label: { Label("Add profile", systemImage: "plus") } .frame(height: 38) } } footer: { Text("Tap to activate profile.") .foregroundColor(theme.colors.secondary) .font(.body) .padding(.top, 8) } } .toolbar { if #available(iOS 16, *) { EditButton() } } .navigationTitle("Your chat profiles") .modifier(ThemedBackground(grouped: true)) .searchable(text: $searchTextOrPassword, placement: .navigationBarDrawer(displayMode: .always)) .autocorrectionDisabled(true) .textInputAutocapitalization(.never) .onAppear { if showHiddenProfilesNotice && m.users.count > 1 { alert = .hiddenProfilesNotice } } .confirmationDialog("Delete chat profile?", isPresented: $showDeleteConfirmation, titleVisibility: .visible) { deleteModeButton("Profile and server connections", true) deleteModeButton("Local profile data only", false) } .appSheet(item: $selectedUser) { user in HiddenProfileView(user: user, profileHidden: $profileHidden) } .onChange(of: profileHidden) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + 10) { withAnimation { profileHidden = false } } } .appSheet(item: $profileAction) { action in profileActionView(action) } .alert(item: $alert) { alert in switch alert { case let .deleteUser(user, delSMPQueues): return Alert( title: Text("Delete user profile?"), message: Text("All chats and messages will be deleted - this cannot be undone!"), primaryButton: .destructive(Text("Delete")) { Task { await removeUser(user, delSMPQueues, viewPwd: userViewPassword(user)) } }, secondaryButton: .cancel() ) case .hiddenProfilesNotice: return Alert( title: Text("Make profile private!"), message: Text("You can hide or mute a user profile - swipe it to the right."), primaryButton: .default(Text("Don't show again")) { showHiddenProfilesNotice = false }, secondaryButton: .default(Text("Ok")) ) case .muteProfileAlert: return Alert( title: Text("Muted when inactive!"), message: Text("You will still receive calls and notifications from muted profiles when they are active."), primaryButton: .default(Text("Don't show again")) { showMuteProfileAlert = false }, secondaryButton: .default(Text("Ok")) ) case let .activateUserError(error: err): return Alert( title: Text("Error switching profile!"), message: Text(err) ) case let .error(title, error): return mkAlert(title: title, message: error) } } } private func filteredUsers() -> [UserInfo] { let s = trimmedSearchTextOrPassword let lower = s.localizedLowercase return m.users.filter { u in if (u.user.activeUser || !u.user.hidden) && (s == "" || u.user.chatViewName.localizedLowercase.contains(lower)) { return true } return correctPassword(u.user, s) } } private var visibleUsersCount: Int { m.users.filter({ u in !u.user.hidden }).count } private func correctPassword(_ user: User, _ pwd: String) -> Bool { if let ph = user.viewPwdHash { return pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash } return false } private func userViewPassword(_ user: User) -> String? { !user.hidden ? nil : trimmedSearchTextOrPassword } @ViewBuilder private func profileActionView(_ action: UserProfileAction) -> some View { let passwordValid = actionPassword == actionPassword.trimmingCharacters(in: .whitespaces) let passwordField = PassphraseField(key: $actionPassword, placeholder: "Profile password", valid: passwordValid) let actionEnabled: (User) -> Bool = { user in actionPassword != "" && passwordValid && correctPassword(user, actionPassword) } List { switch action { case let .deleteUser(user, delSMPQueues): actionHeader("Delete profile", user) Section { passwordField settingsRow("trash", color: theme.colors.secondary) { Button("Delete chat profile", role: .destructive) { profileAction = nil Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) } } .disabled(!actionEnabled(user)) } } footer: { if actionEnabled(user) { Text("All chats and messages will be deleted - this cannot be undone!") .foregroundColor(theme.colors.secondary) .font(.callout) } } case let .unhideUser(user): actionHeader("Unhide profile", user) Section { passwordField settingsRow("lock.open", color: theme.colors.secondary) { Button("Unhide chat profile") { profileAction = nil setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: actionPassword) } } .disabled(!actionEnabled(user)) } } } } .modifier(ThemedBackground()) } @ViewBuilder func actionHeader(_ title: LocalizedStringKey, _ user: User) -> some View { Text(title) .font(.title) .bold() .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .listRowBackground(Color.clear) Section() { ProfilePreview(profileOf: user).padding(.leading, -8) } } private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View { Button(title, role: .destructive) { if let user = userToDelete { if passwordEntryRequired(user) { profileAction = .deleteUser(user: user, delSMPQueues: delSMPQueues) } else { alert = .deleteUser(user: user, delSMPQueues: delSMPQueues) } } } } private func passwordEntryRequired(_ user: User) -> Bool { user.hidden && user.activeUser && !correctPassword(user, trimmedSearchTextOrPassword) } private func removeUser(_ user: User, _ delSMPQueues: Bool, viewPwd: String?) async { do { if user.activeUser { if let newActive = m.users.first(where: { u in !u.user.activeUser && !u.user.hidden }) { try await changeActiveUserAsync_(newActive.user.userId, viewPwd: nil) try await deleteUser() } else { // Deleting the last visible user while having hidden one(s) try await deleteUser() try await changeActiveUserAsync_(nil, viewPwd: nil) try? await stopChatAsync() await MainActor.run { onboardingStageDefault.set(.step1_SimpleXInfo) m.onboardingStage = .step1_SimpleXInfo dismissAllSheets() } } } else { try await deleteUser() } } catch let error { logger.error("Error deleting user profile: \(error)") let a = getErrorAlert(error, "Error deleting user profile") alert = .error(title: a.title, error: a.message) } func deleteUser() async throws { try await apiDeleteUser(user.userId, delSMPQueues, viewPwd: viewPwd) await MainActor.run { withAnimation { m.removeUser(user) } } } } @ViewBuilder private func userView(_ user: User) -> some View { let v = Button { Task { do { try await changeActiveUserAsync_(user.userId, viewPwd: userViewPassword(user)) dismissAllSheets() } catch { await MainActor.run { alert = .activateUserError(error: responseError(error)) } } } } label: { HStack { ProfileImage(imageStr: user.image, size: 38) .padding(.trailing, 12) Text(user.chatViewName) Spacer() if user.activeUser { Image(systemName: "checkmark").foregroundColor(theme.colors.onBackground) } else if user.hidden { Image(systemName: "lock").foregroundColor(theme.colors.secondary) } else if !user.showNtfs { Image(systemName: "speaker.slash").foregroundColor(theme.colors.secondary) } else { Image(systemName: "checkmark").foregroundColor(.clear) } } } .foregroundColor(theme.colors.onBackground) .swipeActions(edge: .leading, allowsFullSwipe: true) { if user.hidden { Button("Unhide") { if passwordEntryRequired(user) { profileAction = .unhideUser(user: user) } else { setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: trimmedSearchTextOrPassword) } } } .tint(.green) } else { if visibleUsersCount > 1 { Button("Hide") { selectedUser = user } .tint(.gray) } Group { if user.showNtfs { Button("Mute") { setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) { try await apiMuteUser(user.userId) } } } else { Button("Unmute") { setUserPrivacy(user) { try await apiUnmuteUser(user.userId) } } } } .tint(theme.colors.primary) } } if #available(iOS 16, *) { v } else { v.swipeActions(edge: .trailing, allowsFullSwipe: true) { Button("Delete", role: .destructive) { confirmDeleteUser(user) } } } } private func confirmDeleteUser(_ user: User) { showDeleteConfirmation = true userToDelete = user } private func setUserPrivacy(_ user: User, successAlert: UserProfilesAlert? = nil, _ api: @escaping () async throws -> User) { Task { do { let u = try await api() await MainActor.run { withAnimation { m.updateUser(u) } if successAlert != nil { alert = successAlert } } } catch let error { let a = getErrorAlert(error, "Error updating user privacy") alert = .error(title: a.title, error: a.message) } } } } public func chatPasswordHash(_ pwd: String, _ salt: String) -> String { var cPwd = pwd.cString(using: .utf8)! var cSalt = salt.cString(using: .utf8)! let cHash = chat_password_hash(&cPwd, &cSalt)! let hash = fromCString(cHash) return hash } public func correctPassword(_ user: User, _ pwd: String) -> Bool { if let ph = user.viewPwdHash { return pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash } return false } struct UserProfilesView_Previews: PreviewProvider { static var previews: some View { UserProfilesView() } }