-
-
Notifications
You must be signed in to change notification settings - Fork 107
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
tmolitor-stud-tu
merged 16 commits into
monal-im:develop
from
matthewrfennell:1094-contact-list-swiftui
Aug 29, 2024
Merged
SwiftUI contact list #1205
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
384ca7a
Correct ContentUnavailableShimView filename
matthewrfennell e1282d6
Create wrapper for ContentUnavailableView.search
matthewrfennell f80b89a
Create NumberlessBadge helper view
matthewrfennell c69f272
Add basic SwiftUI ContactsView implementation
matthewrfennell 902587e
Sort contacts in ContactsView
matthewrfennell 5673be3
Implement searching for contacts in ContactsView
matthewrfennell 43893ce
Make AddTopLevelNavigation tint monalGreen
matthewrfennell d45fec1
Refactor back button in AddTopLevelNavigation
matthewrfennell d0375b7
Correct Add Group form spacing in popover
matthewrfennell 0e2a09e
Use ContactsView over ContactsViewController
matthewrfennell 6582b0b
Add sizeClass to ActiveChatsViewController
matthewrfennell dfff9c6
Update sizeClass on view controller size change
matthewrfennell 3bd10ff
Only show back button when style class is compact
matthewrfennell 6c6f9a9
Use computed property for deletable group members
matthewrfennell 8f1bf8b
Disallow full swipe in group member list
matthewrfennell 8ff851d
Animate group member list changes
matthewrfennell File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
// 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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
|
@@ -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.")) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 :)