Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SwiftUI contact list #1205

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Monal/Classes/ActiveChatsViewController.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ NS_ASSUME_NONNULL_BEGIN
@class chatViewController;
@class MLCall;

@interface SizeClassWrapper: NSObject
@property (atomic) UIUserInterfaceSizeClass horizontal;
@end

@interface ActiveChatsViewController : UITableViewController <DZNEmptyDataSetSource, DZNEmptyDataSetDelegate>

@property (nonatomic, strong) UITableView* chatListTable;
Expand All @@ -26,6 +30,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, weak) IBOutlet UIBarButtonItem* composeButton;
@property (nonatomic, strong) chatViewController* currentChatViewController;
@property (nonatomic, strong) UIActivityIndicatorView* spinner;
@property (atomic, strong) SizeClassWrapper* sizeClass;

-(void) showCallContactNotFoundAlert:(NSString*) jid;
-(void) callContact:(MLContact*) contact withUIKitSender:(_Nullable id) sender;
Expand All @@ -52,6 +57,7 @@ NS_ASSUME_NONNULL_BEGIN
-(void) segueToIntroScreensIfNeeded;
-(void) resetViewQueue;
-(void) dismissCompleteViewChainWithAnimation:(BOOL) animation andCompletion:(monal_void_block_t _Nullable) completion;
-(void) updateSizeClass;

@end

Expand Down
16 changes: 13 additions & 3 deletions Monal/Classes/ActiveChatsViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ @interface ActiveChatsViewController() {
@property (atomic, strong) NSMutableArray* pinnedContacts;
@end

@implementation SizeClassWrapper
@end

@implementation ActiveChatsViewController

enum activeChatsControllerSections {
Expand Down Expand Up @@ -282,6 +285,9 @@ -(void) viewDidLoad

self.view = self.chatListTable;

self.sizeClass = [SizeClassWrapper new];
[self updateSizeClass];

NSNotificationCenter* nc = [NSNotificationCenter defaultCenter];
[nc addObserver:self selector:@selector(handleRefreshDisplayNotification:) name:kMonalRefresh object:nil];
[nc addObserver:self selector:@selector(handleContactRemoved:) name:kMonalContactRemoved object:nil];
Expand Down Expand Up @@ -1106,15 +1112,19 @@ -(void) prepareForSegue:(UIStoryboardSegue*) segue sender:(id) sender
return;
}

UINavigationController* nav = segue.destinationViewController;
ContactsViewController* contacts = (ContactsViewController*)nav.topViewController;
contacts.selectContact = ^(MLContact* selectedContact) {
contactCompletion callback = ^(MLContact* selectedContact) {
DDLogVerbose(@"Got selected contact from contactlist ui: %@", selectedContact);
[self presentChatWithContact:selectedContact];
};
UIViewController* contactsView = [[SwiftuiInterface new] makeContactsViewWithDismisser: callback onButton: self.composeButton];
[self presentViewController:contactsView animated:YES completion:^{}];
}
}

-(void) updateSizeClass {
self.sizeClass.horizontal = self.view.traitCollection.horizontalSizeClass;
}

-(NSMutableArray*) getChatArrayForSection:(size_t) section
{
NSMutableArray* chatArray = nil;
Expand Down
196 changes: 196 additions & 0 deletions Monal/Classes/ContactsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
//
// ContactsView.swift
// Monal
//
// Created by Matthew Fennell <[email protected]> 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) -> ()

@State private var shouldPresentRemoveContactAlert: Bool = false

private var removeContactButtonText: String {
if (!isDeletable) {
return "Cannot delete notes to self"
}
return contact.isGroup ? "Remove Conversation" : "Remove Contact"
}

private var removeContactConfirmationTitle: String {
contact.isGroup ? "Leave this converstion?" : "Remove \(contact.contactJid) from contacts?"
}

private var removeContactConfirmationDetail: String {
contact.isGroup ? "" : "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) -> ()) {
self.contact = contact
self._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) }) {
// The only purpose of this NavigationLink is making the button it contains look nice.
// In other words: have a screen-wide touch target and the chveron on the right of the screen.
// This avoids having to do manual button styling that might have to be recreated in the future.
NavigationLink(destination: EmptyView()) {
HStack {
ContactEntry(contact: ObservableKVOWrapper<MLContact>(contact))
Spacer()
Button {
selectedContactForContactDetails = ObservableKVOWrapper<MLContact>(contact)
} label: {
Image(systemName: "info.circle")
.tint(.blue)
.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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's very nice! could you add that behaviour to the group members list, too?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't forget this one :)

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

@State private var searchText: String = ""
@State private var selectedContactForContactDetails: ObservableKVOWrapper<MLContact>? = nil

private static func shouldDisplayContact(contact: MLContact) -> Bool {
#if IS_QUICKSY
return true
#endif
return contact.isSubscribedTo || contact.hasOutgoingContactRequest || contact.isSubscribedFrom
}

private var contactList: [MLContact] {
return 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) {
return (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)
}

init(contacts: Contacts, delegate: SheetDismisserProtocol, dismissWithContact: @escaping (MLContact) -> ()) {
self.contacts = contacts
self.delegate = delegate
self.dismissWithContact = dismissWithContact
}

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)
.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")
.tint(monalGreen)
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")
.tint(monalGreen)
}
}
.sheet(item: $selectedContactForContactDetails) { selectedContact in
AnyView(AddTopLevelNavigation(withDelegate: delegate, to: ContactDetails(delegate: SheetDismisserProtocol(), contact: selectedContact)))
}
}
}

class Contacts: ObservableObject {
@Published var contacts: Set<MLContact>
@Published var requestCount: Int

init() {
self.contacts = Set(DataLayer.sharedInstance().contactList())
self.requestCount = DataLayer.sharedInstance().allContactRequests().count

NotificationCenter.default.addObserver(self, selector: #selector(refreshContacts), name: NSNotification.Name("kMonalContactRemoved"), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(refreshContacts), name: NSNotification.Name("kMonalContactRefresh"), object: nil)
}

@objc
private func refreshContacts() {
self.contacts = Set(DataLayer.sharedInstance().contactList())
self.requestCount = DataLayer.sharedInstance().allContactRequests().count
}
}
6 changes: 5 additions & 1 deletion Monal/Classes/ContentUnavailableShimView.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// ContentNotAvailableView.swift
// ContentUnavailableShimView.swift
// Monal
//
// Created by Matthew Fennell <[email protected]> on 05/08/2024.
Expand Down Expand Up @@ -40,6 +40,10 @@ struct ContentUnavailableShimView: View {
}
}

extension ContentUnavailableShimView {
static var search: ContentUnavailableShimView = ContentUnavailableShimView("No Results", systemImage: "magnifyingglass", description: Text("Check the spelling or try a new search."))
}

#Preview {
ContentUnavailableShimView("Cannot Display", systemImage: "iphone.homebutton.slash", description: Text("Cannot display for this reason."))
}
11 changes: 10 additions & 1 deletion Monal/Classes/CreateGroupMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ struct CreateGroupMenu: View {
showAlert = true
}

// When a Form is placed inside a Popover, and the horizontal size class is regular, the spacing chosen by SwiftUI is incorrect.
// In particular, the spacing between the top of the first element and the navigation bar is too small, meaning the two overlap.
// This only happens when the view is inside a popover, and the horizontal size class is regular.
// Therefore, it is inconvenient to apply some manual spacing, as this we would have to work out in which situations it should be applied.
// Placing a Text view inside the header causes SwiftUI to add consistent spacing in all situations.
var popoverFormSpacingWorkaround: some View {
Text("")
}

var body: some View {
Form {
if connectedAccounts.isEmpty {
Expand All @@ -44,7 +53,7 @@ struct CreateGroupMenu: View {
}
else
{
Section() {
Section(header: popoverFormSpacingWorkaround) {
if connectedAccounts.count > 1 {
Picker(selection: $selectedAccount, label: Text("Use account")) {
ForEach(Array(self.connectedAccounts.enumerated()), id: \.element) { idx, account in
Expand Down
11 changes: 11 additions & 0 deletions Monal/Classes/MLSplitViewDelegate.m
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,22 @@ -(void) splitViewControllerDidExpand:(UISplitViewController*) splitViewControlle
if([splitViewController.viewControllers count] > 1)
secondaryController = splitViewController.viewControllers[1];

if([primaryController isKindOfClass:NSClassFromString(@"ActiveChatsViewController")])
[(ActiveChatsViewController*)primaryController updateSizeClass];

if([primaryController isKindOfClass:NSClassFromString(@"ActiveChatsViewController")] && [secondaryController isKindOfClass:NSClassFromString(@"MLPlaceholderViewController")])
[(ActiveChatsViewController*)primaryController presentSplitPlaceholder];

if([primaryController isKindOfClass:NSClassFromString(@"MLSettingsTableViewController")] && [secondaryController isKindOfClass:NSClassFromString(@"MLPlaceholderViewController")])
[(MLSettingsTableViewController*)primaryController presentSplitPlaceholder];
}

-(void) splitViewControllerDidCollapse:(UISplitViewController*) splitViewController
{
UIViewController* primaryController = ((UINavigationController*)splitViewController.viewControllers[0]).viewControllers[0];

if([primaryController isKindOfClass:NSClassFromString(@"ActiveChatsViewController")])
[(ActiveChatsViewController*)primaryController updateSizeClass];
}

@end
31 changes: 19 additions & 12 deletions Monal/Classes/MemberList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,10 @@ struct MemberList: View {
}

ForEach(memberList, id:\.self) { contact in
var isDeletable: Bool {
ownUserHasAffiliationToRemove(contact: contact)
}

if !contact.isSelfChat {
HStack {
HStack {
Expand Down Expand Up @@ -295,23 +299,26 @@ struct MemberList: View {
view
}
}
.deleteDisabled(!ownUserHasAffiliationToRemove(contact: contact))
}
}
.onDelete(perform: { memberIdx in
let member = memberList[memberIdx.first!]
showActionSheet(title: Text("Remove \(mucAffiliationToString(affiliations[member]))?"), description: self.muc.mucType == "group" ? Text("Do you want to remove that user from this group? That user won't be able to enter it again until added back to the group.") : Text("Do you want to remove that user from this channel? That user will be able to enter it again if you don't block them.")) {
showPromisingLoadingOverlay(self.overlay, headlineView: Text("Removing \(mucAffiliationToString(affiliations[member]))"), descriptionView: Text("Removing \(member.contactJid as String)...")) {
promisifyAction {
account.mucProcessor.setAffiliation("none", ofUser: member.contactJid, inMuc: self.muc.contactJid)
.swipeActions(allowsFullSwipe: false) {
Button("Delete") {
showActionSheet(title: Text("Remove \(mucAffiliationToString(affiliations[contact]))?"), description: self.muc.mucType == "group" ? Text("Do you want to remove that user from this group? That user won't be able to enter it again until added back to the group.") : Text("Do you want to remove that user from this channel? That user will be able to enter it again if you don't block them.")) {
showPromisingLoadingOverlay(self.overlay, headlineView: Text("Removing \(mucAffiliationToString(affiliations[contact]))"), descriptionView: Text("Removing \(contact.contactJid as String)...")) {
promisifyAction {
account.mucProcessor.setAffiliation("none", ofUser: contact.contactJid, inMuc: self.muc.contactJid)
}
}.catch { error in
showAlert(title:Text("Error removing user!"), description:Text("\(String(describing:error))"))
}
}
}
}.catch { error in
showAlert(title:Text("Error removing user!"), description:Text("\(String(describing:error))"))
.tint(.red)
.disabled(!isDeletable)
}
}
})
}
}
}
.animation(.default, value: memberList)
.actionSheet(isPresented: $showActionSheet) {
ActionSheet(
title: actionSheetPrompt.title,
Expand Down
Loading