another.im-ios/Monal/Classes/ContactsView.swift
2024-11-22 17:34:56 +01:00

195 lines
8 KiB
Swift

//
// ContactsView.swift
// Monal
//
// Created by Matthew Fennell <matthew@fennell.dev> on 10/08/2024.
// Copyright © 2024 monal-im.org. All rights reserved.
//
import SwiftUI
struct ContactViewEntry: View {
private let contact: MLContact
@Binding private var selectedContactForContactDetails: ObservableKVOWrapper<MLContact>?
private let dismissWithContact: (MLContact) -> Void
@State private var shouldPresentRemoveContactAlert: Bool = false
private var removeContactButtonText: String {
if !isDeletable {
return "Cannot delete notes to self"
}
return contact.isMuc ? "Remove Conversation" : "Remove Contact"
}
private var removeContactConfirmationTitle: String {
contact.isMuc ? "Leave this converstion?" : "Remove \(contact.contactJid) from contacts?"
}
private var removeContactConfirmationDetail: String {
contact.isMuc ? "" : "They will no longer see when you are online. They may not be able to access your encryption keys."
}
private var isDeletable: Bool {
!contact.isSelfChat
}
init(contact: MLContact, selectedContactForContactDetails: Binding<ObservableKVOWrapper<MLContact>?>, dismissWithContact: @escaping (MLContact) -> Void) {
self.contact = contact
_selectedContactForContactDetails = selectedContactForContactDetails
self.dismissWithContact = dismissWithContact
}
var body: some View {
// Apple's list dividers only extend as far left as the left-most text in the view.
// This means, by default, that the dividers on this screen would not extend all the way to the left of the view.
// This combination of HStack with spacing of 0, and empty text at the left of the view, is a workaround to override this behaviour.
// See https://stackoverflow.com/a/76698909
HStack(spacing: 0) {
Text("").frame(maxWidth: 0)
Button(action: { dismissWithContact(contact) }) {
HStack {
ContactEntry(contact: ObservableKVOWrapper<MLContact>(contact))
Spacer()
Button {
selectedContactForContactDetails = ObservableKVOWrapper<MLContact>(contact)
} label: {
Image(systemName: "info.circle")
.imageScale(.large)
}
.accessibilityLabel("Open contact details")
}
}
}
.swipeActions(allowsFullSwipe: false) {
// We do not use a Button with destructive role here as we would like to display the confirmation dialog first.
// A destructive role would dismiss the row immediately, without waiting for the confirmation.
Button(removeContactButtonText) {
shouldPresentRemoveContactAlert = true
}
.tint(isDeletable ? .red : .gray)
.disabled(!isDeletable)
}
.confirmationDialog(removeContactConfirmationTitle, isPresented: $shouldPresentRemoveContactAlert, titleVisibility: .visible) {
Button(role: .cancel) {} label: {
Text("No")
}
Button(role: .destructive) {
MLXMPPManager.sharedInstance().remove(contact)
} label: {
Text("Yes")
}
} message: {
Text(removeContactConfirmationDetail)
}
}
}
struct ContactsView: View {
@ObservedObject private var contacts: Contacts
private let delegate: SheetDismisserProtocol
private let dismissWithContact: (MLContact) -> Void
@State private var searchText: String = ""
@State private var selectedContactForContactDetails: ObservableKVOWrapper<MLContact>? = nil
init(contacts: Contacts, delegate: SheetDismisserProtocol, dismissWithContact: @escaping (MLContact) -> Void) {
self.contacts = contacts
self.delegate = delegate
self.dismissWithContact = dismissWithContact
}
private static func shouldDisplayContact(contact: MLContact) -> Bool {
#if IS_QUICKSY
return true
#endif
return contact.isSubscribedTo || contact.hasOutgoingContactRequest || contact.isSubscribedFrom
}
private var contactList: [MLContact] {
contacts.contacts
.filter(ContactsView.shouldDisplayContact)
.sorted { ContactsView.sortingCriteria($0) < ContactsView.sortingCriteria($1) }
}
private var searchResults: [MLContact] {
if searchText.isEmpty { return contactList }
return contactList.filter { searchMatchesContact(contact: $0, search: searchText) }
}
private static func sortingCriteria(_ contact: MLContact) -> (String, String) {
(contact.contactDisplayName.lowercased(), contact.contactJid.lowercased())
}
private func searchMatchesContact(contact: MLContact, search: String) -> Bool {
let jid = contact.contactJid.lowercased()
let name = contact.contactDisplayName.lowercased()
let search = search.lowercased()
return jid.contains(search) || name.contains(search)
}
var body: some View {
List {
ForEach(searchResults, id: \.self) { contact in
ContactViewEntry(contact: contact, selectedContactForContactDetails: $selectedContactForContactDetails, dismissWithContact: dismissWithContact)
}
}
.animation(.default, value: contactList)
.navigationTitle("Contacts")
.listStyle(.plain)
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
.overlay {
if contactList.isEmpty {
ContentUnavailableShimView("You need friends for this ride", systemImage: "figure.wave", description: Text("Add new contacts with the + button above. Your friends will pop up here when they can talk"))
} else if searchResults.isEmpty {
ContentUnavailableShimView.search
}
}
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
NavigationLink(destination: CreateGroupMenu(delegate: SheetDismisserProtocol())) {
Image(systemName: "person.3.fill")
}
.accessibilityLabel("Create contact group")
NavigationLink(destination: AddContactMenu(delegate: SheetDismisserProtocol(), dismissWithNewContact: dismissWithContact)) {
Image(systemName: "person.fill.badge.plus")
.overlay { NumberlessBadge($contacts.requestCount) }
}
.accessibilityLabel(contacts.requestCount > 0 ? "Add contact (contact requests pending)" : "Add New Contact")
}
}
.sheet(item: $selectedContactForContactDetails) { selectedContact in
AnyView(AddTopLevelNavigation(withDelegate: delegate, to: ContactDetails(delegate: delegate, contact: selectedContact)))
}
}
}
class Contacts: ObservableObject {
@Published var contacts: Set<MLContact>
@Published var requestCount: Int
private var subscriptions: Set<AnyCancellable> = Set()
init() {
contacts = Set(DataLayer.sharedInstance().contactList())
requestCount = DataLayer.sharedInstance().allContactRequests().count
subscriptions = [
NotificationCenter.default.publisher(for: NSNotification.Name("kMonalContactRemoved"))
.receive(on: DispatchQueue.main)
.sink { _ in self.refreshContacts() },
NotificationCenter.default.publisher(for: NSNotification.Name("kMonalContactRefresh"))
.receive(on: DispatchQueue.main)
.sink { _ in self.refreshContacts() }
]
}
private func refreshContacts() {
contacts = Set(DataLayer.sharedInstance().contactList())
requestCount = DataLayer.sharedInstance().allContactRequests().count
}
}