mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
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
369 lines
14 KiB
Swift
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)
|
|
}
|