simplex-chat/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift
Evgeny fb4475027d
ios: new user picker (#4821)
* ios: new user picker (#4770)

* current user picker progress

* one hand picker

* thin bullet icon

* more user picker buttons

* button clickable areas

* divider padding

* extra space after sun

* send current user option to address view

* add unread count badge

* with anim for apperance close

* edit current profile from picker

* remove you section from settings

* remove help and support

* simplify

* move settings and sun to same row

* remove redundant vstack

* long press on sun/moon switches to system setting

* remove back button from migrate device

* smooth profile transitions

* close user picker on list profiles

* fix dismiss on migrate from device

* fix dismiss when deleting last visible user while having hidden users

* picker visibility toggle tweaks

* remove strange square from profile switcher click

* dirty way to save auto accept settings on dismiss

* Revert "dirty way to save auto accept settings on dismiss"

This reverts commit e7b19ee8aa.

* consistent animation on user picker toggle

* change space between profiles

* remove result

* ignore result

* unread badge

* move to sheet

* half sheet on one hand ui

* fix dismiss on device migration

* fix desktop connect

* sun to meet other action icons

* fill bullet list button

* fix tap in settings to take full width

* icon sizings and paddings

* open settings in same sheet

* apply same trick as other buttons for ligth toggle

* layout

* open profiles sheet large when +3 users

* layout

* layout

* paddings

* paddings

* remove show progress

* always small user picker

* fixed height

* open all actions as sheets

* type, color

* simpler and more effective way of avoid moving around on user select

* dismiss user profiles sheet on user change

* connect desktop back button remove

* remove back buttons from user address view

* remove porgress

* header inside list

* alert on auto accept unsaved changes

* Cancel -> Discard

* revert

* fix connect to desktop

* remove extra space

* fix share inside multi sheet

* user picker and options as separate sheet

* revert showShareSheet

* fix current profile and all profiles selection

* change alert

* update

* cleanup user address

* remove func

* alert on unsaved changes in chat prefs

* fix layout

* cleanup

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>

* ios: fix switching profiles (#4822)

* ios: different user picker layout (#4826)

* ios: different user picker layout

* remove section

* layout, color

* color

* remove activeUser

* fix gradient

* recursive sheets

* gradient padding

* share sheet

* layout

* dismiss sheets

---------

Co-authored-by: Levitating Pineapple <noreply@levitatingpineapple.com>

* ios: use the same way to share from all sheets (#4829)

* ios: close user picker before opening other sheets

* Revert "share sheet"

This reverts commit 0064155825.

* dismiss/show via callback

* Revert "ios: close user picker before opening other sheets"

This reverts commit 19110398f8.

* ios: show alerts from sheets (#4839)

* padding

* remove gradient

* cleanup

* simplify settings

* padding

---------

Co-authored-by: Diogo <diogofncunha@gmail.com>
Co-authored-by: Levitating Pineapple <noreply@levitatingpineapple.com>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-09-10 09:31:53 +01:00

418 lines
16 KiB
Swift

//
// 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()
}
}