Signal-iOS/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.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()
}
}