session-ios/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift

472 lines
17 KiB
Swift
Raw Normal View History

//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc
protocol ConversationSearchViewDelegate: class {
func conversationSearchViewWillBeginDragging()
}
@objc
2018-09-11 06:16:54 +02:00
class ConversationSearchViewController: UITableViewController, BlockListCacheDelegate {
@objc
public weak var delegate: ConversationSearchViewDelegate?
2018-06-13 17:37:01 +02:00
@objc
public var searchText = "" {
didSet {
AssertIsOnMainThread()
2018-06-13 17:37:01 +02:00
2018-06-13 17:46:23 +02:00
// Use a slight delay to debounce updates.
2018-06-13 19:02:20 +02:00
refreshSearchResults()
2018-06-13 17:37:01 +02:00
}
}
2019-01-16 22:53:48 +01:00
var searchResultSet: HomeScreenSearchResultSet = HomeScreenSearchResultSet.empty {
didSet {
AssertIsOnMainThread()
updateSeparators()
}
}
var uiDatabaseConnection: YapDatabaseConnection {
return OWSPrimaryStorage.shared().uiDatabaseConnection
}
var searcher: FullTextSearcher {
return FullTextSearcher.shared
}
2018-06-12 21:12:14 +02:00
private var contactsManager: OWSContactsManager {
return Environment.shared.contactsManager
2018-06-12 21:12:14 +02:00
}
enum SearchSection: Int {
case noResults
case conversations
case contacts
case messages
}
2018-07-23 21:03:07 +02:00
private var hasThemeChanged = false
2018-09-11 06:16:54 +02:00
var blockListCache: BlockListCache!
2018-09-09 21:08:23 +02:00
2018-06-13 17:37:01 +02:00
// MARK: View Lifecycle
2018-06-13 15:54:18 +02:00
override func viewDidLoad() {
super.viewDidLoad()
2018-06-12 17:27:32 +02:00
2018-09-11 06:16:54 +02:00
blockListCache = BlockListCache()
blockListCache.startObservingAndSyncState(delegate: self)
2018-06-09 07:00:07 +02:00
tableView.rowHeight = UITableViewAutomaticDimension
2018-06-15 17:08:01 +02:00
tableView.estimatedRowHeight = 60
2018-08-22 22:30:12 +02:00
tableView.separatorColor = Theme.cellSeparatorColor
2018-06-09 07:00:07 +02:00
2018-06-11 20:24:29 +02:00
tableView.register(EmptySearchResultCell.self, forCellReuseIdentifier: EmptySearchResultCell.reuseIdentifier)
2018-06-12 17:27:32 +02:00
tableView.register(HomeViewCell.self, forCellReuseIdentifier: HomeViewCell.cellReuseIdentifier())
tableView.register(ContactTableViewCell.self, forCellReuseIdentifier: ContactTableViewCell.reuseIdentifier())
2018-06-13 17:37:01 +02:00
NotificationCenter.default.addObserver(self,
selector: #selector(uiDatabaseModified),
name: .OWSUIDatabaseConnectionDidUpdate,
2018-06-13 17:37:01 +02:00
object: OWSPrimaryStorage.shared().dbNotificationObject)
2018-07-23 21:03:07 +02:00
NotificationCenter.default.addObserver(self,
selector: #selector(themeDidChange),
name: NSNotification.Name.ThemeDidChange,
object: nil)
applyTheme()
updateSeparators()
2018-07-23 21:03:07 +02:00
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard hasThemeChanged else {
return
}
hasThemeChanged = false
applyTheme()
self.tableView.reloadData()
}
deinit {
NotificationCenter.default.removeObserver(self)
2018-06-13 17:37:01 +02:00
}
@objc internal func uiDatabaseModified(notification: NSNotification) {
AssertIsOnMainThread()
2018-06-13 17:37:01 +02:00
2018-06-13 19:02:20 +02:00
refreshSearchResults()
}
2018-07-23 21:03:07 +02:00
@objc internal func themeDidChange(notification: NSNotification) {
AssertIsOnMainThread()
2018-07-23 21:03:07 +02:00
applyTheme()
self.tableView.reloadData()
hasThemeChanged = true
}
private func applyTheme() {
AssertIsOnMainThread()
2018-07-23 21:03:07 +02:00
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)
}
2018-06-11 18:15:46 +02:00
// MARK: UITableViewDelegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)
2018-06-11 18:15:46 +02:00
guard let searchSection = SearchSection(rawValue: indexPath.section) else {
2018-08-27 16:27:48 +02:00
owsFailDebug("unknown section selected.")
2018-06-11 18:15:46 +02:00
return
}
2018-06-11 18:15:46 +02:00
switch searchSection {
case .noResults:
2018-08-27 16:27:48 +02:00
owsFailDebug("shouldn't be able to tap 'no results' section")
2018-06-11 18:15:46 +02:00
case .conversations:
2018-06-11 19:51:15 +02:00
let sectionResults = searchResultSet.conversations
guard let searchResult = sectionResults[safe: indexPath.row] else {
2018-08-27 16:27:48 +02:00
owsFailDebug("unknown row selected.")
2018-06-11 19:51:15 +02:00
return
}
2018-06-11 19:51:15 +02:00
let thread = searchResult.thread
Faster conversation presentation. There are multiple places in the codebase we present a conversation. We used to have some very conservative machinery around how this was done, for fear of failing to present the call view controller, which would have left a hidden call in the background. We've since addressed that concern more thoroughly via the separate calling UIWindow. As such, the remaining presentation machinery is overly complex and inflexible for what we need. Sometimes we want to animate-push the conversation. (tap on home, tap on "send message" in contact card/group members) Sometimes we want to dismiss a modal, to reveal the conversation behind it (contact picker, group creation) Sometimes we want to present the conversation with no animation (becoming active from a notification) We also want to ensure that we're never pushing more than one conversation view controller, which was previously a problem since we were "pushing" a newly constructed VC in response to these myriad actions. It turned out there were certain code paths that caused multiple actions to be fired in rapid succession which pushed multiple ConversationVC's. The built-in method: `setViewControllers:animated` easily ensures we only have one ConversationVC on the stack, while being composable enough to faciliate the various more efficient animations we desire. The only thing lost with the complex methods is that the naive `presentViewController:` can fail, e.g. if another view is already presented. E.g. if an alert appears *just* before the user taps compose, the contact picker will fail to present. Since we no longer depend on this for presenting the CallViewController, this isn't catostrophic, and in fact, arguable preferable, since we want the user to read and dismiss any alert explicitly. // FREEBIE
2018-08-18 22:54:35 +02:00
SignalApp.shared().presentConversation(for: thread.threadRecord, action: .compose, animated: true)
2018-06-11 18:15:46 +02:00
case .contacts:
2018-06-11 19:51:15 +02:00
let sectionResults = searchResultSet.contacts
guard let searchResult = sectionResults[safe: indexPath.row] else {
2018-08-27 16:27:48 +02:00
owsFailDebug("unknown row selected.")
2018-06-11 19:51:15 +02:00
return
}
Faster conversation presentation. There are multiple places in the codebase we present a conversation. We used to have some very conservative machinery around how this was done, for fear of failing to present the call view controller, which would have left a hidden call in the background. We've since addressed that concern more thoroughly via the separate calling UIWindow. As such, the remaining presentation machinery is overly complex and inflexible for what we need. Sometimes we want to animate-push the conversation. (tap on home, tap on "send message" in contact card/group members) Sometimes we want to dismiss a modal, to reveal the conversation behind it (contact picker, group creation) Sometimes we want to present the conversation with no animation (becoming active from a notification) We also want to ensure that we're never pushing more than one conversation view controller, which was previously a problem since we were "pushing" a newly constructed VC in response to these myriad actions. It turned out there were certain code paths that caused multiple actions to be fired in rapid succession which pushed multiple ConversationVC's. The built-in method: `setViewControllers:animated` easily ensures we only have one ConversationVC on the stack, while being composable enough to faciliate the various more efficient animations we desire. The only thing lost with the complex methods is that the naive `presentViewController:` can fail, e.g. if another view is already presented. E.g. if an alert appears *just* before the user taps compose, the contact picker will fail to present. Since we no longer depend on this for presenting the CallViewController, this isn't catostrophic, and in fact, arguable preferable, since we want the user to read and dismiss any alert explicitly. // FREEBIE
2018-08-18 22:54:35 +02:00
SignalApp.shared().presentConversation(forRecipientId: searchResult.recipientId, action: .compose, animated: true)
2018-06-11 18:15:46 +02:00
case .messages:
2018-06-11 19:51:15 +02:00
let sectionResults = searchResultSet.messages
guard let searchResult = sectionResults[safe: indexPath.row] else {
2018-08-27 16:27:48 +02:00
owsFailDebug("unknown row selected.")
2018-06-11 19:51:15 +02:00
return
}
2018-06-11 19:51:15 +02:00
let thread = searchResult.thread
2018-06-11 21:31:54 +02:00
SignalApp.shared().presentConversation(for: thread.threadRecord,
Faster conversation presentation. There are multiple places in the codebase we present a conversation. We used to have some very conservative machinery around how this was done, for fear of failing to present the call view controller, which would have left a hidden call in the background. We've since addressed that concern more thoroughly via the separate calling UIWindow. As such, the remaining presentation machinery is overly complex and inflexible for what we need. Sometimes we want to animate-push the conversation. (tap on home, tap on "send message" in contact card/group members) Sometimes we want to dismiss a modal, to reveal the conversation behind it (contact picker, group creation) Sometimes we want to present the conversation with no animation (becoming active from a notification) We also want to ensure that we're never pushing more than one conversation view controller, which was previously a problem since we were "pushing" a newly constructed VC in response to these myriad actions. It turned out there were certain code paths that caused multiple actions to be fired in rapid succession which pushed multiple ConversationVC's. The built-in method: `setViewControllers:animated` easily ensures we only have one ConversationVC on the stack, while being composable enough to faciliate the various more efficient animations we desire. The only thing lost with the complex methods is that the naive `presentViewController:` can fail, e.g. if another view is already presented. E.g. if an alert appears *just* before the user taps compose, the contact picker will fail to present. Since we no longer depend on this for presenting the CallViewController, this isn't catostrophic, and in fact, arguable preferable, since we want the user to read and dismiss any alert explicitly. // FREEBIE
2018-08-18 22:54:35 +02:00
action: .none,
focusMessageId: searchResult.messageId,
animated: true)
2018-06-11 18:15:46 +02:00
}
}
2018-06-09 07:00:07 +02:00
// MARK: UITableViewDataSource
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
guard let searchSection = SearchSection(rawValue: section) else {
2018-08-27 16:27:48 +02:00
owsFailDebug("unknown section: \(section)")
return 0
}
switch searchSection {
case .noResults:
2018-06-11 20:24:29 +02:00
return searchResultSet.isEmpty ? 1 : 0
case .conversations:
return searchResultSet.conversations.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:
2018-06-11 20:24:29 +02:00
guard let cell = tableView.dequeueReusableCell(withIdentifier: EmptySearchResultCell.reuseIdentifier) as? EmptySearchResultCell else {
2018-08-27 16:27:48 +02:00
owsFailDebug("cell was unexpectedly nil")
2018-06-11 20:24:29 +02:00
return UITableViewCell()
}
guard indexPath.row == 0 else {
2018-08-27 16:27:48 +02:00
owsFailDebug("searchResult was unexpected index")
2018-06-11 20:24:29 +02:00
return UITableViewCell()
}
2018-07-23 21:03:07 +02:00
OWSTableItem.configureCell(cell)
2018-06-11 20:24:29 +02:00
let searchText = self.searchResultSet.searchText
cell.configure(searchText: searchText)
return cell
case .conversations:
2018-06-12 17:27:32 +02:00
guard let cell = tableView.dequeueReusableCell(withIdentifier: HomeViewCell.cellReuseIdentifier()) as? HomeViewCell else {
2018-08-27 16:27:48 +02:00
owsFailDebug("cell was unexpectedly nil")
return UITableViewCell()
}
guard let searchResult = self.searchResultSet.conversations[safe: indexPath.row] else {
2018-08-27 16:27:48 +02:00
owsFailDebug("searchResult was unexpectedly nil")
return UITableViewCell()
}
cell.configure(withThread: searchResult.thread, isBlocked: isBlocked(thread: searchResult.thread))
return cell
case .contacts:
2018-06-12 17:27:32 +02:00
guard let cell = tableView.dequeueReusableCell(withIdentifier: ContactTableViewCell.reuseIdentifier()) as? ContactTableViewCell else {
2018-08-27 16:27:48 +02:00
owsFailDebug("cell was unexpectedly nil")
2018-06-11 18:20:49 +02:00
return UITableViewCell()
}
guard let searchResult = self.searchResultSet.contacts[safe: indexPath.row] else {
2018-08-27 16:27:48 +02:00
owsFailDebug("searchResult was unexpectedly nil")
2018-06-11 18:20:49 +02:00
return UITableViewCell()
}
2018-10-25 15:35:08 +02:00
cell.configure(withRecipientId: searchResult.signalAccount.recipientId)
2018-06-11 18:20:49 +02:00
return cell
case .messages:
2018-06-12 17:27:32 +02:00
guard let cell = tableView.dequeueReusableCell(withIdentifier: HomeViewCell.cellReuseIdentifier()) as? HomeViewCell else {
2018-08-27 16:27:48 +02:00
owsFailDebug("cell was unexpectedly nil")
2018-06-08 19:23:17 +02:00
return UITableViewCell()
}
guard let searchResult = self.searchResultSet.messages[safe: indexPath.row] else {
2018-08-27 16:27:48 +02:00
owsFailDebug("searchResult was unexpectedly nil")
return UITableViewCell()
2018-06-08 19:23:17 +02:00
}
2018-06-12 17:27:32 +02:00
var overrideSnippet = NSAttributedString()
2018-06-13 16:23:12 +02:00
var overrideDate: Date?
if searchResult.messageId != nil {
if let messageDate = searchResult.messageDate {
overrideDate = messageDate
2018-06-13 15:54:18 +02:00
} else {
2018-08-27 16:27:48 +02:00
owsFailDebug("message search result is missing message timestamp")
2018-06-12 17:27:32 +02:00
}
2018-06-13 15:54:18 +02:00
// Note that we only use the snippet for message results,
// not conversation results. HomeViewCell will generate
// a snippet for conversations that reflects the latest
// contents.
if let messageSnippet = searchResult.snippet {
2018-08-07 23:12:16 +02:00
overrideSnippet = NSAttributedString(string: messageSnippet,
attributes: [
2018-08-16 23:27:20 +02:00
NSAttributedStringKey.foregroundColor: Theme.secondaryColor
2018-08-07 23:12:16 +02:00
])
2018-06-13 15:54:18 +02:00
} else {
2018-08-27 16:27:48 +02:00
owsFailDebug("message search result is missing message snippet")
2018-06-12 17:27:32 +02:00
}
}
cell.configure(withThread: searchResult.thread,
2018-09-09 21:08:23 +02:00
isBlocked: isBlocked(thread: searchResult.thread),
2018-06-13 16:23:12 +02:00
overrideSnippet: overrideSnippet,
overrideDate: overrideDate)
2018-06-12 17:27:32 +02:00
2018-06-08 19:23:17 +02:00
return cell
}
}
override func numberOfSections(in tableView: UITableView) -> Int {
2018-06-11 20:24:29 +02:00
return 4
}
2018-08-16 23:27:20 +02:00
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
2018-08-22 22:30:12 +02:00
guard nil != self.tableView(tableView, titleForHeaderInSection: section) else {
2018-08-16 23:27:20 +02:00
return 0
}
2018-08-22 22:30:12 +02:00
return UITableViewAutomaticDimension
2018-08-16 23:27:20 +02:00
}
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
guard let title = self.tableView(tableView, titleForHeaderInSection: section) else {
return nil
}
let label = UILabel()
label.textColor = Theme.secondaryColor
label.text = title
label.font = UIFont.ows_dynamicTypeBody.ows_mediumWeight()
label.tag = section
let hMargin: CGFloat = 15
let vMargin: CGFloat = 4
let wrapper = UIView()
wrapper.backgroundColor = Theme.offBackgroundColor
wrapper.addSubview(label)
label.autoPinWidthToSuperview(withMargin: hMargin)
label.autoPinHeightToSuperview(withMargin: vMargin)
return wrapper
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
guard let searchSection = SearchSection(rawValue: section) else {
2018-08-27 16:27:48 +02:00
owsFailDebug("unknown section: \(section)")
return nil
}
switch searchSection {
case .noResults:
return nil
case .conversations:
if searchResultSet.conversations.count > 0 {
return NSLocalizedString("SEARCH_SECTION_CONVERSATIONS", comment: "section header for search results that match existing conversations (either group or contact conversations)")
} 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
}
}
}
2018-09-11 06:16:54 +02:00
// MARK: BlockListCacheDelegate
func blockListCacheDidUpdate(_ blocklistCache: BlockListCache) {
refreshSearchResults()
}
2018-06-13 17:46:23 +02:00
// MARK: Update Search Results
var refreshTimer: Timer?
2018-06-13 19:02:20 +02:00
private func refreshSearchResults() {
AssertIsOnMainThread()
2018-06-13 17:46:23 +02:00
2018-06-13 19:02:20 +02:00
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
}
2018-06-13 17:46:23 +02:00
if refreshTimer != nil {
// Don't start a new refresh timer if there's already one active.
return
}
refreshTimer?.invalidate()
2018-06-13 19:02:20 +02:00
refreshTimer = WeakTimer.scheduledTimer(timeInterval: 0.1, target: self, userInfo: nil, repeats: false) { [weak self] _ in
2018-06-13 17:46:23 +02:00
guard let strongSelf = self else {
return
}
strongSelf.updateSearchResults(searchText: strongSelf.searchText)
strongSelf.refreshTimer = nil
}
}
2018-06-13 17:37:01 +02:00
private func updateSearchResults(searchText: String) {
guard searchText.stripped.count > 0 else {
2019-01-16 22:53:48 +01:00
self.searchResultSet = HomeScreenSearchResultSet.empty
2018-06-12 21:12:14 +02:00
self.tableView.reloadData()
return
}
2019-01-16 22:53:48 +01:00
var searchResults: HomeScreenSearchResultSet?
2018-08-22 19:17:56 +02:00
self.uiDatabaseConnection.asyncRead({[weak self] transaction in
guard let strongSelf = self else { return }
2019-01-16 22:53:48 +01:00
searchResults = strongSelf.searcher.searchForHomeScreen(searchText: searchText, transaction: transaction, contactsManager: strongSelf.contactsManager)
},
2018-08-22 19:17:56 +02:00
completionBlock: { [weak self] in
AssertIsOnMainThread()
2018-08-22 19:17:56 +02:00
guard let strongSelf = self else { return }
guard let results = searchResults else {
owsFailDebug("searchResults was unexpectedly nil")
return
}
2018-08-22 19:17:56 +02:00
strongSelf.searchResultSet = results
strongSelf.tableView.reloadData()
})
}
// MARK: - UIScrollViewDelegate
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
delegate?.conversationSearchViewWillBeginDragging()
}
2018-09-09 21:08:23 +02:00
// MARK: -
private func isBlocked(thread: ThreadViewModel) -> Bool {
2018-09-11 06:16:54 +02:00
return self.blockListCache.isBlocked(thread: thread.threadRecord)
2018-09-09 21:08:23 +02:00
}
2018-06-09 07:00:07 +02:00
}
2018-06-11 20:24:29 +02:00
class EmptySearchResultCell: UITableViewCell {
static let reuseIdentifier = "EmptySearchResultCell"
let messageLabel: UILabel
override init(style: UITableViewCellStyle, 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()
}
required init?(coder aDecoder: NSCoder) {
2018-08-27 16:21:03 +02:00
notImplemented()
2018-06-11 20:24:29 +02:00
}
public func configure(searchText: String) {
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
self.messageLabel.text = messageText
2018-08-22 20:21:19 +02:00
messageLabel.textColor = Theme.primaryColor
messageLabel.font = UIFont.ows_dynamicTypeBody
2018-06-11 20:24:29 +02:00
}
}