|
|
|
@ -20,7 +20,7 @@ private extension Log.Category {
|
|
|
|
|
class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UITableViewDelegate, UITableViewDataSource {
|
|
|
|
|
fileprivate typealias SectionModel = ArraySection<SearchSection, SessionThreadViewModel>
|
|
|
|
|
|
|
|
|
|
fileprivate struct SearchResultData {
|
|
|
|
|
fileprivate struct SearchResultData: Equatable {
|
|
|
|
|
var state: SearchResultsState
|
|
|
|
|
var data: [SectionModel]
|
|
|
|
|
}
|
|
|
|
@ -50,70 +50,31 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI
|
|
|
|
|
// MARK: - Variables
|
|
|
|
|
|
|
|
|
|
private let dependencies: Dependencies
|
|
|
|
|
private lazy var defaultSearchResults: SearchResultData = {
|
|
|
|
|
let nonalphabeticNameTitle: String = "#" // stringlint:ignore
|
|
|
|
|
let contacts: [SessionThreadViewModel] = dependencies[singleton: .storage].read { [dependencies] db -> [SessionThreadViewModel]? in
|
|
|
|
|
try SessionThreadViewModel
|
|
|
|
|
.defaultContactsQuery(using: dependencies)
|
|
|
|
|
.fetchAll(db)
|
|
|
|
|
}
|
|
|
|
|
.defaulting(to: [])
|
|
|
|
|
.sorted {
|
|
|
|
|
$0.displayName.lowercased() < $1.displayName.lowercased()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var groupedContacts: [String: SectionModel] = [:]
|
|
|
|
|
contacts.forEach { contactViewModel in
|
|
|
|
|
guard !contactViewModel.threadIsNoteToSelf else {
|
|
|
|
|
groupedContacts[""] = SectionModel(
|
|
|
|
|
model: .groupedContacts(title: ""),
|
|
|
|
|
elements: [contactViewModel]
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let displayName = NSMutableString(string: contactViewModel.displayName)
|
|
|
|
|
CFStringTransform(displayName, nil, kCFStringTransformToLatin, false)
|
|
|
|
|
CFStringTransform(displayName, nil, kCFStringTransformStripDiacritics, false)
|
|
|
|
|
private var defaultSearchResults: SearchResultData = SearchResultData(state: .none, data: []) {
|
|
|
|
|
didSet {
|
|
|
|
|
guard searchText.isEmpty else { return }
|
|
|
|
|
|
|
|
|
|
let initialCharacter: String = (displayName.length > 0 ? displayName.substring(to: 1) : "")
|
|
|
|
|
let section: String = initialCharacter.capitalized.isSingleAlphabet ?
|
|
|
|
|
initialCharacter.capitalized :
|
|
|
|
|
nonalphabeticNameTitle
|
|
|
|
|
/// If we have no search term then the contact list should be showing, so update the results and reload the table
|
|
|
|
|
self.searchResultSet = defaultSearchResults
|
|
|
|
|
|
|
|
|
|
if groupedContacts[section] == nil {
|
|
|
|
|
groupedContacts[section] = SectionModel(
|
|
|
|
|
model: .groupedContacts(title: section),
|
|
|
|
|
elements: []
|
|
|
|
|
)
|
|
|
|
|
switch Thread.isMainThread {
|
|
|
|
|
case true: self.tableView.reloadData()
|
|
|
|
|
case false: DispatchQueue.main.async { self.tableView.reloadData() }
|
|
|
|
|
}
|
|
|
|
|
groupedContacts[section]?.elements.append(contactViewModel)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return SearchResultData(
|
|
|
|
|
state: .defaultContacts,
|
|
|
|
|
data: groupedContacts.values.sorted { sectionModel0, sectionModel1 in
|
|
|
|
|
let title0: String = {
|
|
|
|
|
switch sectionModel0.model {
|
|
|
|
|
case .groupedContacts(let title): return title
|
|
|
|
|
default: return ""
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
let title1: String = {
|
|
|
|
|
switch sectionModel1.model {
|
|
|
|
|
case .groupedContacts(let title): return title
|
|
|
|
|
default: return ""
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
if ![title0, title1].contains(nonalphabeticNameTitle) {
|
|
|
|
|
return title0 < title1
|
|
|
|
|
private lazy var defaultSearchResultsObservation = ValueObservation
|
|
|
|
|
.trackingConstantRegion { [dependencies] db -> [SessionThreadViewModel] in
|
|
|
|
|
try SessionThreadViewModel
|
|
|
|
|
.defaultContactsQuery(using: dependencies)
|
|
|
|
|
.fetchAll(db)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return title1 == nonalphabeticNameTitle
|
|
|
|
|
.map { GlobalSearchViewController.processDefaultSearchResults($0) }
|
|
|
|
|
.removeDuplicates()
|
|
|
|
|
.handleEvents(didFail: { Log.error(.cat, "Observation failed with error: \($0)") })
|
|
|
|
|
private var defaultDataChangeObservable: DatabaseCancellable? {
|
|
|
|
|
didSet { oldValue?.cancel() } // Cancel the old observable if there was one
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
@ThreadSafeObject private var currentSearchCancellable: AnyCancellable? = nil
|
|
|
|
|
private lazy var searchResultSet: SearchResultData = defaultSearchResults
|
|
|
|
@ -187,6 +148,18 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI
|
|
|
|
|
setupNavigationBar()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
|
|
|
super.viewWillAppear(animated)
|
|
|
|
|
|
|
|
|
|
defaultDataChangeObservable = dependencies[singleton: .storage].start(
|
|
|
|
|
defaultSearchResultsObservation,
|
|
|
|
|
onError: { _ in },
|
|
|
|
|
onChange: { [weak self] updatedDefaultResults in
|
|
|
|
|
self?.defaultSearchResults = updatedDefaultResults
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public override func viewDidAppear(_ animated: Bool) {
|
|
|
|
|
super.viewDidAppear(animated)
|
|
|
|
|
searchBar.becomeFirstResponder()
|
|
|
|
@ -195,6 +168,8 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI
|
|
|
|
|
public override func viewWillDisappear(_ animated: Bool) {
|
|
|
|
|
super.viewWillDisappear(animated)
|
|
|
|
|
|
|
|
|
|
self.defaultDataChangeObservable = nil
|
|
|
|
|
|
|
|
|
|
UIView.performWithoutAnimation {
|
|
|
|
|
searchBar.resignFirstResponder()
|
|
|
|
|
}
|
|
|
|
@ -241,6 +216,64 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI
|
|
|
|
|
|
|
|
|
|
// MARK: - Update Search Results
|
|
|
|
|
|
|
|
|
|
private static func processDefaultSearchResults(_ contacts: [SessionThreadViewModel]) -> SearchResultData {
|
|
|
|
|
let nonalphabeticNameTitle: String = "#" // stringlint:ignore
|
|
|
|
|
|
|
|
|
|
return SearchResultData(
|
|
|
|
|
state: .defaultContacts,
|
|
|
|
|
data: contacts
|
|
|
|
|
.sorted { lhs, rhs in lhs.displayName.lowercased() < rhs.displayName.lowercased() }
|
|
|
|
|
.reduce(into: [String: SectionModel]()) { result, next in
|
|
|
|
|
guard !next.threadIsNoteToSelf else {
|
|
|
|
|
result[""] = SectionModel(
|
|
|
|
|
model: .groupedContacts(title: ""),
|
|
|
|
|
elements: [next]
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let displayName = NSMutableString(string: next.displayName)
|
|
|
|
|
CFStringTransform(displayName, nil, kCFStringTransformToLatin, false)
|
|
|
|
|
CFStringTransform(displayName, nil, kCFStringTransformStripDiacritics, false)
|
|
|
|
|
|
|
|
|
|
let initialCharacter: String = (displayName.length > 0 ? displayName.substring(to: 1) : "")
|
|
|
|
|
let section: String = (initialCharacter.capitalized.isSingleAlphabet ?
|
|
|
|
|
initialCharacter.capitalized :
|
|
|
|
|
nonalphabeticNameTitle
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if result[section] == nil {
|
|
|
|
|
result[section] = SectionModel(
|
|
|
|
|
model: .groupedContacts(title: section),
|
|
|
|
|
elements: []
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
result[section]?.elements.append(next)
|
|
|
|
|
}
|
|
|
|
|
.values
|
|
|
|
|
.sorted { sectionModel0, sectionModel1 in
|
|
|
|
|
let title0: String = {
|
|
|
|
|
switch sectionModel0.model {
|
|
|
|
|
case .groupedContacts(let title): return title
|
|
|
|
|
default: return ""
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
let title1: String = {
|
|
|
|
|
switch sectionModel1.model {
|
|
|
|
|
case .groupedContacts(let title): return title
|
|
|
|
|
default: return ""
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
if ![title0, title1].contains(nonalphabeticNameTitle) {
|
|
|
|
|
return title0 < title1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return title1 == nonalphabeticNameTitle
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func refreshSearchResults() {
|
|
|
|
|
refreshTimer?.invalidate()
|
|
|
|
|
refreshTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 0.1, using: dependencies) { [weak self] _ in
|
|
|
|
@ -382,6 +415,32 @@ extension GlobalSearchViewController {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
|
|
|
|
let section: SectionModel = self.searchResultSet.data[indexPath.section]
|
|
|
|
|
|
|
|
|
|
switch section.model {
|
|
|
|
|
case .contactsAndGroups, .messages: return nil
|
|
|
|
|
case .groupedContacts:
|
|
|
|
|
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
|
|
|
|
|
|
|
|
|
/// No actions for `Note to Self`
|
|
|
|
|
guard !threadViewModel.threadIsNoteToSelf else { return nil }
|
|
|
|
|
|
|
|
|
|
return UIContextualAction.configuration(
|
|
|
|
|
for: UIContextualAction.generateSwipeActions(
|
|
|
|
|
[.block, .deleteContact],
|
|
|
|
|
for: .trailing,
|
|
|
|
|
indexPath: indexPath,
|
|
|
|
|
tableView: tableView,
|
|
|
|
|
threadViewModel: threadViewModel,
|
|
|
|
|
viewController: self,
|
|
|
|
|
navigatableStateHolder: nil,
|
|
|
|
|
using: dependencies
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func show(
|
|
|
|
|
threadId: String,
|
|
|
|
|
threadVariant: SessionThread.Variant,
|
|
|
|
|