session-ios/Session/Conversations/ConversationSearch.swift
Morgan Pretty 345b693225 Fixed build issues, bugs, added unit tests and added the ConvoInfoVolatile handling
Added the unit tests for the ConvoInfoVolatile
Added icons to the swipe actions
Updated jobs to be able to be prioritised (and added priorities to the launch jobs to avoid some odd behaviours)
Fixed some build issues resulting from merging
Fixed an issue with the open group pubkey encoding
Fixed an issue where an imageView could get it's image set on a background thread
Fixed a bug where the swipe actions weren't getting theming applied when the theme changed
Fixed a bug where scheduling code after the next db transaction completes couldn't be nested (resulting in code not running)
Fixed a bug where the PagedDataObserver might not notify of unobserved changes if they reverted previous unobserved changes
Fixed a couple of incorrect SQL ordering use cases (was overriding instead of appending ordering)
Fixed an issue where the app would re-upload the avatar every launch (only affected this branch)
Fixed an issue where the home screen wouldn't update group avatars when their profile data changed
2023-02-01 18:12:36 +11:00

369 lines
14 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import GRDB
import SignalUtilitiesKit
import SignalCoreKit
public class StyledSearchController: UISearchController {
public override var preferredStatusBarStyle: UIStatusBarStyle {
return ThemeManager.currentTheme.statusBarStyle
}
let stubbableSearchBar: StubbableSearchBar = StubbableSearchBar()
override public var searchBar: UISearchBar {
get { stubbableSearchBar }
}
}
public class StubbableSearchBar: UISearchBar {
weak var stubbedNextResponder: UIResponder?
public override var next: UIResponder? {
if let stubbedNextResponder = self.stubbedNextResponder {
return stubbedNextResponder
}
return super.next
}
}
public class ConversationSearchController: NSObject {
public static let minimumSearchTextLength: UInt = 2
private let threadId: String
public weak var delegate: ConversationSearchControllerDelegate?
public let uiSearchController: StyledSearchController = StyledSearchController(searchResultsController: nil)
public let resultsBar: SearchResultsBar = SearchResultsBar()
private var lastSearchText: String?
// MARK: Initializer
public init(threadId: String) {
self.threadId = threadId
super.init()
self.resultsBar.resultsBarDelegate = self
self.uiSearchController.delegate = self
self.uiSearchController.searchResultsUpdater = self
self.uiSearchController.hidesNavigationBarDuringPresentation = false
self.uiSearchController.searchBar.inputAccessoryView = resultsBar
}
}
// MARK: - UISearchControllerDelegate
extension ConversationSearchController: UISearchControllerDelegate {
public func didPresentSearchController(_ searchController: UISearchController) {
delegate?.didPresentSearchController?(searchController)
}
public func didDismissSearchController(_ searchController: UISearchController) {
delegate?.didDismissSearchController?(searchController)
}
}
// MARK: - UISearchResultsUpdating
extension ConversationSearchController: UISearchResultsUpdating {
public func updateSearchResults(for searchController: UISearchController) {
Logger.verbose("searchBar.text: \( searchController.searchBar.text ?? "<blank>")")
guard
let searchText: String = searchController.searchBar.text?.stripped,
searchText.count >= ConversationSearchController.minimumSearchTextLength
else {
self.resultsBar.updateResults(results: nil)
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil, searchText: nil)
return
}
let threadId: String = self.threadId
DispatchQueue.global(qos: .default).async { [weak self] in
let results: [Interaction.TimestampInfo]? = Storage.shared.read { db -> [Interaction.TimestampInfo] in
self?.resultsBar.willStartSearching(readConnection: db)
return try Interaction.idsForTermWithin(
threadId: threadId,
pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText)
)
.fetchAll(db)
}
// If we didn't get results back then we most likely interrupted the query so
// should ignore the results (if there are no results we would succeed and get
// an empty array back)
guard let results: [Interaction.TimestampInfo] = results else { return }
DispatchQueue.main.async {
guard let strongSelf = self else { return }
self?.resultsBar.stopLoading()
self?.resultsBar.updateResults(results: results)
self?.delegate?.conversationSearchController(strongSelf, didUpdateSearchResults: results, searchText: searchText)
}
}
}
}
// MARK: - SearchResultsBarDelegate
extension ConversationSearchController: SearchResultsBarDelegate {
func searchResultsBar(
_ searchResultsBar: SearchResultsBar,
setCurrentIndex currentIndex: Int,
results: [Interaction.TimestampInfo]
) {
guard let interactionInfo: Interaction.TimestampInfo = results[safe: currentIndex] else { return }
self.delegate?.conversationSearchController(self, didSelectInteractionInfo: interactionInfo)
}
}
protocol SearchResultsBarDelegate: AnyObject {
func searchResultsBar(
_ searchResultsBar: SearchResultsBar,
setCurrentIndex currentIndex: Int,
results: [Interaction.TimestampInfo]
)
}
public final class SearchResultsBar: UIView {
private var readConnection: Atomic<Database?> = Atomic(nil)
private var results: Atomic<[Interaction.TimestampInfo]?> = Atomic(nil)
var currentIndex: Int?
weak var resultsBarDelegate: SearchResultsBarDelegate?
public override var intrinsicContentSize: CGSize { CGSize.zero }
private lazy var label: UILabel = {
let result = UILabel()
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
result.themeTextColor = .textPrimary
return result
}()
private lazy var upButton: UIButton = {
let icon = #imageLiteral(resourceName: "ic_chevron_up").withRenderingMode(.alwaysTemplate)
let result: UIButton = UIButton()
result.setImage(icon, for: UIControl.State.normal)
result.themeTintColor = .primary
result.addTarget(self, action: #selector(handleUpButtonTapped), for: UIControl.Event.touchUpInside)
return result
}()
private lazy var downButton: UIButton = {
let icon = #imageLiteral(resourceName: "ic_chevron_down").withRenderingMode(.alwaysTemplate)
let result: UIButton = UIButton()
result.setImage(icon, for: UIControl.State.normal)
result.themeTintColor = .primary
result.addTarget(self, action: #selector(handleDownButtonTapped), for: UIControl.Event.touchUpInside)
return result
}()
private lazy var loadingIndicator: UIActivityIndicatorView = {
let result = UIActivityIndicatorView(style: .medium)
result.themeTintColor = .textPrimary
result.alpha = 0.5
result.hidesWhenStopped = true
return result
}()
override init(frame: CGRect) {
super.init(frame: frame)
setUpViewHierarchy()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setUpViewHierarchy()
}
private func setUpViewHierarchy() {
autoresizingMask = .flexibleHeight
// Background & blur
let backgroundView = UIView()
backgroundView.themeBackgroundColor = .backgroundSecondary
backgroundView.alpha = Values.lowOpacity
addSubview(backgroundView)
backgroundView.pin(to: self)
let blurView = UIVisualEffectView()
addSubview(blurView)
blurView.pin(to: self)
ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _ in
switch theme.interfaceStyle {
case .light: blurView?.effect = UIBlurEffect(style: .light)
default: blurView?.effect = UIBlurEffect(style: .dark)
}
}
// Separator
let separator = UIView()
separator.themeBackgroundColor = .borderSeparator
separator.set(.height, to: Values.separatorThickness)
addSubview(separator)
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
// Spacers
let spacer1 = UIView.hStretchingSpacer()
let spacer2 = UIView.hStretchingSpacer()
// Button containers
let upButtonContainer = UIView(wrapping: upButton, withInsets: UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0))
let downButtonContainer = UIView(wrapping: downButton, withInsets: UIEdgeInsets(top: 0, left: 0, bottom: 2, right: 0))
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ upButtonContainer, downButtonContainer, spacer1, label, spacer2 ])
mainStackView.axis = .horizontal
mainStackView.spacing = Values.mediumSpacing
mainStackView.isLayoutMarginsRelativeArrangement = true
mainStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.largeSpacing, bottom: Values.smallSpacing, trailing: Values.largeSpacing)
addSubview(mainStackView)
mainStackView.pin(.top, to: .bottom, of: separator)
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
mainStackView.pin(.bottom, to: .bottom, of: self, withInset: -2)
addSubview(loadingIndicator)
loadingIndicator.pin(.left, to: .right, of: label, withInset: 10)
loadingIndicator.centerYAnchor.constraint(equalTo: label.centerYAnchor).isActive = true
// Remaining constraints
label.center(.horizontal, in: self)
}
// MARK: - Actions
@objc public func handleUpButtonTapped() {
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else { return }
guard let currentIndex: Int = currentIndex else { return }
guard currentIndex + 1 < results.count else { return }
let newIndex = currentIndex + 1
self.currentIndex = newIndex
updateBarItems()
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results)
}
@objc public func handleDownButtonTapped() {
Logger.debug("")
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else { return }
guard let currentIndex: Int = currentIndex, currentIndex > 0 else { return }
let newIndex = currentIndex - 1
self.currentIndex = newIndex
updateBarItems()
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results)
}
// MARK: - Content
/// This method will be called within a DB read block
func willStartSearching(readConnection: Database) {
let hasNoExistingResults: Bool = (self.results.wrappedValue?.isEmpty != false)
DispatchQueue.main.async { [weak self] in
if hasNoExistingResults {
self?.label.text = "CONVERSATION_SEARCH_SEARCHING".localized()
}
self?.startLoading()
}
self.readConnection.wrappedValue?.interrupt()
self.readConnection.mutate { $0 = readConnection }
}
func updateResults(results: [Interaction.TimestampInfo]?) {
// We want to ignore search results that don't match the current searchId (this
// will happen when searching large threads with short terms as the shorter terms
// will take much longer to resolve than the longer terms)
currentIndex = {
guard let results: [Interaction.TimestampInfo] = results, !results.isEmpty else { return nil }
if let currentIndex: Int = currentIndex {
return max(0, min(currentIndex, results.count - 1))
}
return 0
}()
self.readConnection.mutate { $0 = nil }
self.results.mutate { $0 = results }
updateBarItems()
if let currentIndex = currentIndex, let results = results {
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: currentIndex, results: results)
}
}
func updateBarItems() {
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else {
label.text = ""
downButton.isEnabled = false
upButton.isEnabled = false
return
}
switch results.count {
case 0:
// Keyboard toolbar label when no messages match the search string
label.text = "CONVERSATION_SEARCH_NO_RESULTS".localized()
case 1:
// Keyboard toolbar label when exactly 1 message matches the search string
label.text = "CONVERSATION_SEARCH_ONE_RESULT".localized()
default:
// Keyboard toolbar label when more than 1 message matches the search string
//
// Embeds {{number/position of the 'currently viewed' result}} and
// the {{total number of results}}
let format = "CONVERSATION_SEARCH_RESULTS_FORMAT".localized()
guard let currentIndex: Int = currentIndex else { return }
label.text = String(format: format, currentIndex + 1, results.count)
}
if let currentIndex: Int = currentIndex {
downButton.isEnabled = currentIndex > 0
upButton.isEnabled = (currentIndex + 1 < results.count)
}
else {
downButton.isEnabled = false
upButton.isEnabled = false
}
}
public func startLoading() {
loadingIndicator.startAnimating()
}
public func stopLoading() {
loadingIndicator.stopAnimating()
}
}
// MARK: - ConversationSearchControllerDelegate
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Interaction.TimestampInfo]?, searchText: String?)
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionInfo: Interaction.TimestampInfo)
}