583 lines
20 KiB
Swift
583 lines
20 KiB
Swift
//
|
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import BonMot
|
|
|
|
@objc
|
|
protocol ConversationSearchViewDelegate: class {
|
|
func conversationSearchViewWillBeginDragging()
|
|
}
|
|
|
|
@objc
|
|
class ConversationSearchViewController: UITableViewController, BlockListCacheDelegate {
|
|
|
|
// MARK: -
|
|
|
|
@objc
|
|
public weak var delegate: ConversationSearchViewDelegate?
|
|
|
|
@objc
|
|
public var searchText = "" {
|
|
didSet {
|
|
AssertIsOnMainThread()
|
|
|
|
// Use a slight delay to debounce updates.
|
|
refreshSearchResults()
|
|
}
|
|
}
|
|
|
|
var searchResultSet: HomeScreenSearchResultSet = HomeScreenSearchResultSet.empty {
|
|
didSet {
|
|
AssertIsOnMainThread()
|
|
|
|
updateSeparators()
|
|
}
|
|
}
|
|
private var lastSearchText: String?
|
|
|
|
var searcher: FullTextSearcher {
|
|
return FullTextSearcher.shared
|
|
}
|
|
|
|
enum SearchSection: Int {
|
|
case noResults
|
|
case contactThreads
|
|
case groupThreads
|
|
case contacts
|
|
case messages
|
|
}
|
|
|
|
private var hasThemeChanged = false
|
|
|
|
var blockListCache: BlockListCache!
|
|
|
|
static let matchSnippetStyle = StringStyle(
|
|
.color(Theme.secondaryTextAndIconColor),
|
|
.xmlRules([
|
|
.style(FullTextSearchFinder.matchTag, StringStyle(.font(UIFont.ows_dynamicTypeBody2.ows_semibold)))
|
|
])
|
|
)
|
|
|
|
// MARK: View Lifecycle
|
|
|
|
init() {
|
|
super.init(style: .grouped)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
blockListCache = BlockListCache()
|
|
blockListCache.startObservingAndSyncState(delegate: self)
|
|
|
|
tableView.rowHeight = UITableView.automaticDimension
|
|
tableView.estimatedRowHeight = 60
|
|
tableView.separatorColor = .clear
|
|
tableView.separatorInset = .zero
|
|
tableView.separatorStyle = .none
|
|
|
|
tableView.register(EmptySearchResultCell.self, forCellReuseIdentifier: EmptySearchResultCell.reuseIdentifier)
|
|
tableView.register(ConversationListCell.self, forCellReuseIdentifier: ConversationListCell.cellReuseIdentifier())
|
|
tableView.register(ContactTableViewCell.self, forCellReuseIdentifier: ContactTableViewCell.reuseIdentifier)
|
|
|
|
databaseStorage.appendUIDatabaseSnapshotDelegate(self)
|
|
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(themeDidChange),
|
|
name: .ThemeDidChange,
|
|
object: nil)
|
|
|
|
applyTheme()
|
|
updateSeparators()
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
|
|
guard hasThemeChanged else {
|
|
return
|
|
}
|
|
hasThemeChanged = false
|
|
|
|
applyTheme()
|
|
self.tableView.reloadData()
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
@objc
|
|
internal func themeDidChange(notification: NSNotification) {
|
|
AssertIsOnMainThread()
|
|
|
|
applyTheme()
|
|
self.tableView.reloadData()
|
|
|
|
hasThemeChanged = true
|
|
}
|
|
|
|
private func applyTheme() {
|
|
AssertIsOnMainThread()
|
|
|
|
self.view.backgroundColor = Theme.backgroundColor
|
|
self.tableView.backgroundColor = Theme.backgroundColor
|
|
}
|
|
|
|
private func updateSeparators() {
|
|
AssertIsOnMainThread()
|
|
|
|
self.tableView.separatorStyle = (searchResultSet.isEmpty
|
|
? UITableViewCell.SeparatorStyle.none
|
|
: UITableViewCell.SeparatorStyle.singleLine)
|
|
}
|
|
|
|
// MARK: UITableViewDelegate
|
|
|
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
tableView.deselectRow(at: indexPath, animated: false)
|
|
|
|
guard let searchSection = SearchSection(rawValue: indexPath.section) else {
|
|
owsFailDebug("unknown section selected.")
|
|
return
|
|
}
|
|
|
|
switch searchSection {
|
|
case .noResults:
|
|
owsFailDebug("shouldn't be able to tap 'no results' section")
|
|
case .contactThreads:
|
|
let sectionResults = searchResultSet.contactThreads
|
|
guard let searchResult = sectionResults[safe: indexPath.row] else {
|
|
owsFailDebug("unknown row selected.")
|
|
return
|
|
}
|
|
|
|
let thread = searchResult.thread
|
|
SignalApp.shared().presentConversation(for: thread.threadRecord, action: .compose, animated: true)
|
|
case .groupThreads:
|
|
let sectionResults = searchResultSet.groupThreads
|
|
guard let searchResult = sectionResults[safe: indexPath.row] else {
|
|
owsFailDebug("unknown row selected.")
|
|
return
|
|
}
|
|
|
|
let thread = searchResult.thread
|
|
SignalApp.shared().presentConversation(for: thread.threadRecord, action: .compose, animated: true)
|
|
case .contacts:
|
|
let sectionResults = searchResultSet.contacts
|
|
guard let searchResult = sectionResults[safe: indexPath.row] else {
|
|
owsFailDebug("unknown row selected.")
|
|
return
|
|
}
|
|
|
|
SignalApp.shared().presentConversation(for: searchResult.recipientAddress, action: .compose, animated: true)
|
|
|
|
case .messages:
|
|
let sectionResults = searchResultSet.messages
|
|
guard let searchResult = sectionResults[safe: indexPath.row] else {
|
|
owsFailDebug("unknown row selected.")
|
|
return
|
|
}
|
|
|
|
let thread = searchResult.thread
|
|
SignalApp.shared().presentConversation(for: thread.threadRecord,
|
|
action: .none,
|
|
focusMessageId: searchResult.messageId,
|
|
animated: true)
|
|
}
|
|
}
|
|
|
|
// MARK: UITableViewDataSource
|
|
|
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
guard let searchSection = SearchSection(rawValue: section) else {
|
|
owsFailDebug("unknown section: \(section)")
|
|
return 0
|
|
}
|
|
|
|
switch searchSection {
|
|
case .noResults:
|
|
return searchResultSet.isEmpty ? 1 : 0
|
|
case .contactThreads:
|
|
return searchResultSet.contactThreads.count
|
|
case .groupThreads:
|
|
return searchResultSet.groupThreads.count
|
|
case .contacts:
|
|
return searchResultSet.contacts.count
|
|
case .messages:
|
|
return searchResultSet.messages.count
|
|
}
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
|
|
guard let searchSection = SearchSection(rawValue: indexPath.section) else {
|
|
return UITableViewCell()
|
|
}
|
|
|
|
switch searchSection {
|
|
case .noResults:
|
|
guard let cell = tableView.dequeueReusableCell(withIdentifier: EmptySearchResultCell.reuseIdentifier) as? EmptySearchResultCell else {
|
|
owsFailDebug("cell was unexpectedly nil")
|
|
return UITableViewCell()
|
|
}
|
|
|
|
guard indexPath.row == 0 else {
|
|
owsFailDebug("searchResult was unexpected index")
|
|
return UITableViewCell()
|
|
}
|
|
|
|
OWSTableItem.configureCell(cell)
|
|
|
|
let searchText = self.searchResultSet.searchText
|
|
cell.configure(searchText: searchText)
|
|
return cell
|
|
case .contactThreads:
|
|
guard let cell = tableView.dequeueReusableCell(withIdentifier: ConversationListCell.cellReuseIdentifier()) as? ConversationListCell else {
|
|
owsFailDebug("cell was unexpectedly nil")
|
|
return UITableViewCell()
|
|
}
|
|
|
|
guard let searchResult = self.searchResultSet.contactThreads[safe: indexPath.row] else {
|
|
owsFailDebug("searchResult was unexpectedly nil")
|
|
return UITableViewCell()
|
|
}
|
|
cell.configure(withThread: searchResult.thread, isBlocked: isBlocked(thread: searchResult.thread))
|
|
return cell
|
|
case .groupThreads:
|
|
guard let cell = tableView.dequeueReusableCell(withIdentifier: ConversationListCell.cellReuseIdentifier()) as? ConversationListCell else {
|
|
owsFailDebug("cell was unexpectedly nil")
|
|
return UITableViewCell()
|
|
}
|
|
|
|
guard let searchResult = self.searchResultSet.groupThreads[safe: indexPath.row] else {
|
|
owsFailDebug("searchResult was unexpectedly nil")
|
|
return UITableViewCell()
|
|
}
|
|
|
|
cell.configure(
|
|
withThread: searchResult.thread,
|
|
isBlocked: isBlocked(thread: searchResult.thread),
|
|
overrideSnippet: searchResult.matchedMembersSnippet?.styled(with: Self.matchSnippetStyle),
|
|
overrideDate: nil
|
|
)
|
|
return cell
|
|
case .contacts:
|
|
guard let cell = tableView.dequeueReusableCell(withIdentifier: ContactTableViewCell.reuseIdentifier) as? ContactTableViewCell else {
|
|
owsFailDebug("cell was unexpectedly nil")
|
|
return UITableViewCell()
|
|
}
|
|
|
|
guard let searchResult = self.searchResultSet.contacts[safe: indexPath.row] else {
|
|
owsFailDebug("searchResult was unexpectedly nil")
|
|
return UITableViewCell()
|
|
}
|
|
cell.configureWithSneakyTransaction(address: searchResult.signalAccount.recipientAddress,
|
|
localUserDisplayMode: .noteToSelf)
|
|
return cell
|
|
case .messages:
|
|
guard let cell = tableView.dequeueReusableCell(withIdentifier: ConversationListCell.cellReuseIdentifier()) as? ConversationListCell else {
|
|
owsFailDebug("cell was unexpectedly nil")
|
|
return UITableViewCell()
|
|
}
|
|
|
|
guard let searchResult = self.searchResultSet.messages[safe: indexPath.row] else {
|
|
owsFailDebug("searchResult was unexpectedly nil")
|
|
return UITableViewCell()
|
|
}
|
|
|
|
var overrideSnippet = NSAttributedString()
|
|
var overrideDate: Date?
|
|
if searchResult.messageId != nil {
|
|
if let messageDate = searchResult.messageDate {
|
|
overrideDate = messageDate
|
|
} else {
|
|
owsFailDebug("message search result is missing message timestamp")
|
|
}
|
|
|
|
// Note that we only use the snippet for message results,
|
|
// not conversation results. CoversationListCell will generate
|
|
// a snippet for conversations that reflects the latest
|
|
// contents.
|
|
if let messageSnippet = searchResult.snippet {
|
|
overrideSnippet = messageSnippet.styled(with: Self.matchSnippetStyle)
|
|
} else {
|
|
owsFailDebug("message search result is missing message snippet")
|
|
}
|
|
}
|
|
|
|
cell.configure(withThread: searchResult.thread,
|
|
isBlocked: isBlocked(thread: searchResult.thread),
|
|
overrideSnippet: overrideSnippet,
|
|
overrideDate: overrideDate)
|
|
|
|
return cell
|
|
}
|
|
}
|
|
|
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
|
return 5
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
|
|
UIView()
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
|
|
.leastNonzeroMagnitude
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
|
guard nil != self.tableView(tableView, titleForHeaderInSection: section) else {
|
|
return .leastNonzeroMagnitude
|
|
}
|
|
return UITableView.automaticDimension
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
|
guard let title = self.tableView(tableView, titleForHeaderInSection: section) else {
|
|
return UIView()
|
|
}
|
|
|
|
let textView = LinkingTextView()
|
|
textView.textColor = Theme.isDarkThemeEnabled ? UIColor.ows_gray05 : UIColor.ows_gray90
|
|
textView.font = UIFont.ows_dynamicTypeBodyClamped.ows_semibold
|
|
textView.text = title
|
|
|
|
var textContainerInset = UIEdgeInsets(
|
|
top: 14,
|
|
left: OWSTableViewController2.cellHOuterLeftMargin(in: view),
|
|
bottom: 8,
|
|
right: OWSTableViewController2.cellHOuterRightMargin(in: view)
|
|
)
|
|
textContainerInset.left += tableView.safeAreaInsets.left
|
|
textContainerInset.right += tableView.safeAreaInsets.right
|
|
textView.textContainerInset = textContainerInset
|
|
|
|
return textView
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
|
guard let searchSection = SearchSection(rawValue: section) else {
|
|
owsFailDebug("unknown section: \(section)")
|
|
return nil
|
|
}
|
|
|
|
switch searchSection {
|
|
case .noResults:
|
|
return nil
|
|
case .contactThreads:
|
|
if searchResultSet.contactThreads.count > 0 {
|
|
return NSLocalizedString("SEARCH_SECTION_CONVERSATIONS", comment: "section header for search results that match existing 1:1 chats")
|
|
} else {
|
|
return nil
|
|
}
|
|
case .groupThreads:
|
|
if searchResultSet.groupThreads.count > 0 {
|
|
return NSLocalizedString("SEARCH_SECTION_GROUPS", comment: "section header for search results that match existing groups")
|
|
} else {
|
|
return nil
|
|
}
|
|
case .contacts:
|
|
if searchResultSet.contacts.count > 0 {
|
|
return NSLocalizedString("SEARCH_SECTION_CONTACTS", comment: "section header for search results that match a contact who doesn't have an existing conversation")
|
|
} else {
|
|
return nil
|
|
}
|
|
case .messages:
|
|
if searchResultSet.messages.count > 0 {
|
|
return NSLocalizedString("SEARCH_SECTION_MESSAGES", comment: "section header for search results that match a message in a conversation")
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: BlockListCacheDelegate
|
|
|
|
func blockListCacheDidUpdate(_ blocklistCache: BlockListCache) {
|
|
refreshSearchResults()
|
|
}
|
|
|
|
// MARK: Update Search Results
|
|
|
|
var refreshTimer: Timer?
|
|
|
|
private func refreshSearchResults() {
|
|
AssertIsOnMainThread()
|
|
|
|
guard !searchResultSet.isEmpty else {
|
|
// To avoid incorrectly showing the "no results" state,
|
|
// always search immediately if the current result set is empty.
|
|
refreshTimer?.invalidate()
|
|
refreshTimer = nil
|
|
|
|
updateSearchResults(searchText: searchText)
|
|
return
|
|
}
|
|
|
|
if refreshTimer != nil {
|
|
// Don't start a new refresh timer if there's already one active.
|
|
return
|
|
}
|
|
|
|
refreshTimer?.invalidate()
|
|
refreshTimer = WeakTimer.scheduledTimer(timeInterval: 0.1, target: self, userInfo: nil, repeats: false) { [weak self] _ in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
|
|
strongSelf.updateSearchResults(searchText: strongSelf.searchText)
|
|
strongSelf.refreshTimer = nil
|
|
}
|
|
}
|
|
|
|
private func updateSearchResults(searchText rawSearchText: String) {
|
|
|
|
let searchText = rawSearchText.stripped
|
|
guard searchText.count > 0 else {
|
|
searchResultSet = HomeScreenSearchResultSet.empty
|
|
lastSearchText = nil
|
|
tableView.reloadData()
|
|
return
|
|
}
|
|
guard lastSearchText != searchText else {
|
|
// Ignoring redundant search.
|
|
return
|
|
}
|
|
|
|
lastSearchText = searchText
|
|
|
|
var searchResults: HomeScreenSearchResultSet?
|
|
self.databaseStorage.asyncRead(block: {[weak self] transaction in
|
|
guard let strongSelf = self else { return }
|
|
searchResults = strongSelf.searcher.searchForHomeScreen(searchText: searchText, transaction: transaction)
|
|
},
|
|
completion: { [weak self] in
|
|
AssertIsOnMainThread()
|
|
guard let strongSelf = self else { return }
|
|
|
|
guard let results = searchResults else {
|
|
owsFailDebug("searchResults was unexpectedly nil")
|
|
return
|
|
}
|
|
guard strongSelf.lastSearchText == searchText else {
|
|
// Discard results from stale search.
|
|
return
|
|
}
|
|
|
|
strongSelf.searchResultSet = results
|
|
strongSelf.tableView.reloadData()
|
|
})
|
|
}
|
|
|
|
// MARK: - UIScrollViewDelegate
|
|
|
|
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
delegate?.conversationSearchViewWillBeginDragging()
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private func isBlocked(thread: ThreadViewModel) -> Bool {
|
|
return self.blockListCache.isBlocked(thread: thread.threadRecord)
|
|
}
|
|
}
|
|
|
|
class EmptySearchResultCell: UITableViewCell {
|
|
static let reuseIdentifier = "EmptySearchResultCell"
|
|
|
|
let messageLabel: UILabel
|
|
let activityIndicator = UIActivityIndicatorView(style: .whiteLarge)
|
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
|
self.messageLabel = UILabel()
|
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
|
|
|
messageLabel.textAlignment = .center
|
|
messageLabel.numberOfLines = 3
|
|
|
|
contentView.addSubview(messageLabel)
|
|
|
|
messageLabel.autoSetDimension(.height, toSize: 150)
|
|
|
|
messageLabel.autoPinEdge(toSuperviewMargin: .top, relation: .greaterThanOrEqual)
|
|
messageLabel.autoPinEdge(toSuperviewMargin: .leading, relation: .greaterThanOrEqual)
|
|
messageLabel.autoPinEdge(toSuperviewMargin: .bottom, relation: .greaterThanOrEqual)
|
|
messageLabel.autoPinEdge(toSuperviewMargin: .trailing, relation: .greaterThanOrEqual)
|
|
|
|
messageLabel.autoVCenterInSuperview()
|
|
messageLabel.autoHCenterInSuperview()
|
|
|
|
messageLabel.setContentHuggingHigh()
|
|
messageLabel.setCompressionResistanceHigh()
|
|
|
|
contentView.addSubview(activityIndicator)
|
|
activityIndicator.autoCenterInSuperview()
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder) {
|
|
notImplemented()
|
|
}
|
|
|
|
public func configure(searchText: String) {
|
|
if searchText.isEmpty {
|
|
activityIndicator.color = Theme.primaryIconColor
|
|
activityIndicator.isHidden = false
|
|
activityIndicator.startAnimating()
|
|
messageLabel.isHidden = true
|
|
messageLabel.text = nil
|
|
} else {
|
|
activityIndicator.stopAnimating()
|
|
activityIndicator.isHidden = true
|
|
messageLabel.isHidden = false
|
|
|
|
let format = NSLocalizedString(
|
|
"HOME_VIEW_SEARCH_NO_RESULTS_FORMAT",
|
|
comment: "Format string when search returns no results. Embeds {{search term}}"
|
|
)
|
|
let messageText: String = NSString(format: format as NSString, searchText) as String
|
|
messageLabel.text = messageText
|
|
|
|
messageLabel.textColor = Theme.primaryTextColor
|
|
messageLabel.font = UIFont.ows_dynamicTypeBody
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationSearchViewController: UIDatabaseSnapshotDelegate {
|
|
|
|
func uiDatabaseSnapshotWillUpdate() {
|
|
AssertIsOnMainThread()
|
|
}
|
|
|
|
func uiDatabaseSnapshotDidUpdate(databaseChanges: UIDatabaseChanges) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard databaseChanges.didUpdateThreads || databaseChanges.didUpdateInteractions else {
|
|
return
|
|
}
|
|
|
|
refreshSearchResults()
|
|
}
|
|
|
|
func uiDatabaseSnapshotDidUpdateExternally() {
|
|
AssertIsOnMainThread()
|
|
|
|
refreshSearchResults()
|
|
}
|
|
|
|
func uiDatabaseSnapshotDidReset() {
|
|
AssertIsOnMainThread()
|
|
|
|
refreshSearchResults()
|
|
}
|
|
}
|