diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index bf1e916bd..8e9e828ae 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -139,6 +139,9 @@ 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */; }; 7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */; }; 7B7CB18B270591630079FF93 /* ShareLogsModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB18A270591630079FF93 /* ShareLogsModal.swift */; }; + 7BA7F4BB279F9F5800B3A466 /* EmptySearchResultCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA7F4BA279F9F5800B3A466 /* EmptySearchResultCell.swift */; }; + 7BA7F4BD27A216B600B3A466 /* Storage+RecentSearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA7F4BC27A216B600B3A466 /* Storage+RecentSearchResults.swift */; }; + 7BA9057E27911C5800998B3C /* GlobalSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA9057D27911C5800998B3C /* GlobalSearchViewController.swift */; }; 7BC01A3E241F40AB00BC7C55 /* NotificationServiceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC01A3D241F40AB00BC7C55 /* NotificationServiceExtension.swift */; }; 7BC01A42241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 7BC01A3B241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7BDCFC08242186E700641C39 /* NotificationServiceExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BDCFC07242186E700641C39 /* NotificationServiceExtensionContext.swift */; }; @@ -1114,6 +1117,9 @@ 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsendRequest.swift; sourceTree = ""; }; 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedMessageView.swift; sourceTree = ""; }; 7B7CB18A270591630079FF93 /* ShareLogsModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLogsModal.swift; sourceTree = ""; }; + 7BA7F4BA279F9F5800B3A466 /* EmptySearchResultCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptySearchResultCell.swift; sourceTree = ""; }; + 7BA7F4BC27A216B600B3A466 /* Storage+RecentSearchResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+RecentSearchResults.swift"; sourceTree = ""; }; + 7BA9057D27911C5800998B3C /* GlobalSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchViewController.swift; sourceTree = ""; }; 7BA6F47DAD18D44D75B7110F /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig"; sourceTree = ""; }; 7BC01A3B241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 7BC01A3D241F40AB00BC7C55 /* NotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtension.swift; sourceTree = ""; }; @@ -2043,6 +2049,16 @@ path = Utilities; sourceTree = ""; }; + 7BA7F4B9279F9F3700B3A466 /* GlobalSearch */ = { + isa = PBXGroup; + children = ( + 7BA9057D27911C5800998B3C /* GlobalSearchViewController.swift */, + 7BA7F4BA279F9F5800B3A466 /* EmptySearchResultCell.swift */, + 7BA7F4BC27A216B600B3A466 /* Storage+RecentSearchResults.swift */, + ); + path = GlobalSearch; + sourceTree = ""; + }; 7BC01A3C241F40AB00BC7C55 /* SessionNotificationServiceExtension */ = { isa = PBXGroup; children = ( @@ -2801,6 +2817,7 @@ children = ( B8BB82A4238F627000BA5194 /* HomeVC.swift */, B83F2B85240C7B8F000A54AB /* NewConversationButtonSet.swift */, + 7BA7F4B9279F9F3700B3A466 /* GlobalSearch */, ); path = Home; sourceTree = ""; @@ -4855,6 +4872,7 @@ B821494625D4D6FF009C0F2A /* URLModal.swift in Sources */, C374EEEB25DA3CA70073A857 /* ConversationTitleView.swift in Sources */, B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */, + 7BA9057E27911C5800998B3C /* GlobalSearchViewController.swift in Sources */, 4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */, 34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */, 344825C6211390C800DB4BD8 /* OWSOrphanDataCleaner.m in Sources */, @@ -4912,6 +4930,7 @@ 76EB054018170B33006006FC /* AppDelegate.m in Sources */, 340FC8B6204DAC8D007AEB0F /* OWSQRCodeScanningViewController.m in Sources */, C33100082558FF6D00070591 /* NewConversationButtonSet.swift in Sources */, + 7BA7F4BB279F9F5800B3A466 /* EmptySearchResultCell.swift in Sources */, C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */, B8BB82A5238F627000BA5194 /* HomeVC.swift in Sources */, C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */, @@ -4939,6 +4958,7 @@ 3427C64320F500E000EEC730 /* OWSMessageTimerView.m in Sources */, B90418E6183E9DD40038554A /* DateUtil.m in Sources */, C33100092558FF6D00070591 /* UserCell.swift in Sources */, + 7BA7F4BD27A216B600B3A466 /* Storage+RecentSearchResults.swift in Sources */, B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */, C374EEE225DA26740073A857 /* LinkPreviewModal.swift in Sources */, 3496956F21A301A100DCFE74 /* OWSBackupLazyRestore.swift in Sources */, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 0a58c831c..c696d30cb 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -7,7 +7,8 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { let isUnsendRequestsEnabled = true // Set to true once unsend requests are done on all platforms let thread: TSThread - let focusedMessageID: String? // This isn't actually used ATM + let focusedMessageID: String? // This is used for global search + var focusedMessageIndexPath: IndexPath? var unreadViewItems: [ConversationViewItem] = [] var scrollButtonConstraint: NSLayoutConstraint? // Search @@ -236,13 +237,17 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat // unreadIndicatorIndex is calculated during loading of the viewItems, so it's // supposed to be accurate. DispatchQueue.main.async { - let firstUnreadMessageIndex = self.viewModel.viewState.unreadIndicatorIndex?.intValue - ?? (self.viewItems.count - self.unreadViewItems.count) - if unreadCount > 0, let viewItem = self.viewItems[ifValid: firstUnreadMessageIndex], let interactionID = viewItem.interaction.uniqueId { - self.scrollToInteraction(with: interactionID, position: .top, isAnimated: false) - self.unreadCountView.alpha = self.scrollButton.alpha + if let focusedMessageID = self.focusedMessageID { + self.scrollToInteraction(with: focusedMessageID, isAnimated: false, highlighted: true) } else { - self.scrollToBottom(isAnimated: false) + let firstUnreadMessageIndex = self.viewModel.viewState.unreadIndicatorIndex?.intValue + ?? (self.viewItems.count - self.unreadViewItems.count) + if unreadCount > 0, let viewItem = self.viewItems[ifValid: firstUnreadMessageIndex], let interactionID = viewItem.interaction.uniqueId { + self.scrollToInteraction(with: interactionID, position: .top, isAnimated: false) + self.unreadCountView.alpha = self.scrollButton.alpha + } else { + self.scrollToBottom(isAnimated: false) + } } self.scrollButton.alpha = self.getScrollButtonOpacity() } @@ -251,6 +256,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + highlightFocusedMessageIfNeeded() didFinishInitialLayout = true markAllAsRead() } @@ -313,6 +319,13 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } } + private func highlightFocusedMessageIfNeeded() { + if let indexPath = focusedMessageIndexPath, let cell = messagesTableView.cellForRow(at: indexPath) as? VisibleMessageCell { + cell.highlight() + focusedMessageIndexPath = nil + } + } + @objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) { guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return } if (newHeight > 0 && baselineKeyboardHeight == 0) { @@ -538,6 +551,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat func showSearchUI() { isShowingSearchUI = true // Search bar + // FIXME: This code is duplicated with SearchBar let searchBar = searchController.uiSearchController.searchBar searchBar.searchBarStyle = .minimal searchBar.barStyle = .black @@ -616,8 +630,11 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat scrollToInteraction(with: interactionID) } - func scrollToInteraction(with interactionID: String, position: UITableView.ScrollPosition = .middle, isAnimated: Bool = true) { + func scrollToInteraction(with interactionID: String, position: UITableView.ScrollPosition = .middle, isAnimated: Bool = true, highlighted: Bool = false) { guard let indexPath = viewModel.ensureLoadWindowContainsInteractionId(interactionID) else { return } messagesTableView.scrollToRow(at: indexPath, at: position, animated: isAnimated) + if highlighted { + focusedMessageIndexPath = indexPath + } } } diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index c564a598e..0a9755acf 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -65,7 +65,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { lazy var bubbleView: UIView = { let result = UIView() - result.layer.cornerRadius = VisibleMessageCell.smallCornerRadius + result.layer.cornerRadius = VisibleMessageCell.largeCornerRadius return result }() @@ -431,10 +431,12 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { } private func updateBubbleViewCorners() { - let maskPath = UIBezierPath(roundedRect: bubbleView.bounds, byRoundingCorners: getCornersToRound(), + let cornersToRound = getCornersToRound() + let maskPath = UIBezierPath(roundedRect: bubbleView.bounds, byRoundingCorners: cornersToRound, cornerRadii: CGSize(width: VisibleMessageCell.largeCornerRadius, height: VisibleMessageCell.largeCornerRadius)) bubbleViewMaskLayer.path = maskPath.cgPath - bubbleView.layer.mask = bubbleViewMaskLayer + bubbleView.layer.cornerRadius = VisibleMessageCell.largeCornerRadius + bubbleView.layer.maskedCorners = getCornerMask(from: cornersToRound) } override func prepareForReuse() { @@ -470,6 +472,18 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { return abs(v.x) > abs(v.y) // It has to be more horizontal than vertical } else { return true + + } + } + + func highlight() { + let shawdowColour = isLightMode ? UIColor.black.cgColor : Colors.accent.cgColor + let opacity : Float = isLightMode ? 0.5 : 1 + bubbleView.setShadow(radius: 10, opacity: opacity, offset: .zero, color: shawdowColour) + DispatchQueue.main.async { + UIView.animate(withDuration: 1.6) { + self.bubbleView.setShadow(radius: 0, opacity: 0, offset: .zero, color: UIColor.clear.cgColor) + } } } @@ -571,6 +585,19 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { return result } + private func getCornerMask(from rectCorner: UIRectCorner) -> CACornerMask { + var cornerMask = CACornerMask() + if rectCorner.contains(.allCorners) { + cornerMask = [ .layerMaxXMinYCorner, .layerMinXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner] + } else { + if rectCorner.contains(.topRight) { cornerMask.insert(.layerMaxXMinYCorner) } + if rectCorner.contains(.topLeft) { cornerMask.insert(.layerMinXMinYCorner) } + if rectCorner.contains(.bottomRight) { cornerMask.insert(.layerMaxXMaxYCorner) } + if rectCorner.contains(.bottomLeft) { cornerMask.insert(.layerMinXMaxYCorner) } + } + return cornerMask + } + private static func getFontSize(for viewItem: ConversationViewItem) -> CGFloat { let baselineFontSize = Values.mediumFontSize switch viewItem.displayableBodyText?.jumbomojiCount { diff --git a/Session/Home/GlobalSearch/EmptySearchResultCell.swift b/Session/Home/GlobalSearch/EmptySearchResultCell.swift new file mode 100644 index 000000000..45402292a --- /dev/null +++ b/Session/Home/GlobalSearch/EmptySearchResultCell.swift @@ -0,0 +1,59 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import NVActivityIndicatorView + +class EmptySearchResultCell: UITableViewCell { + static let reuseIdentifier = "EmptySearchResultCell" + + private lazy var messageLabel: UILabel = { + let result = UILabel() + result.textAlignment = .center + result.numberOfLines = 3 + result.textColor = Colors.text + result.text = NSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS", comment: "") + return result + }() + + private lazy var spinner: NVActivityIndicatorView = { + let result = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: Colors.text, padding: nil) + result.set(.width, to: 40) + result.set(.height, to: 40) + return result + }() + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + backgroundColor = .clear + + 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(spinner) + spinner.autoCenterInSuperview() + } + + required init?(coder aDecoder: NSCoder) { + notImplemented() + } + + public func configure(isLoading: Bool) { + if isLoading { + // Calling stopAnimating() here is a workaround for + // the spinner won't change its colour as the theme changed. + spinner.stopAnimating() + spinner.startAnimating() + messageLabel.isHidden = true + } else { + spinner.stopAnimating() + messageLabel.isHidden = false + } + } +} diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift new file mode 100644 index 000000000..ae6dc757e --- /dev/null +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -0,0 +1,380 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +@objc +class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { + + let isRecentSearchResultsEnabled = false + + @objc public var searchText = "" { + didSet { + AssertIsOnMainThread() + // Use a slight delay to debounce updates. + refreshSearchResults() + } + } + var recentSearchResults: [String] = Array(Storage.shared.getRecentSearchResults().reversed()) + var searchResultSet: HomeScreenSearchResultSet = HomeScreenSearchResultSet.empty + private var lastSearchText: String? + var searcher: FullTextSearcher { + return FullTextSearcher.shared + } + var isLoading = false + + enum SearchSection: Int { + case noResults + case contacts + case messages + case recent + } + + // MARK: UI Components + + internal lazy var searchBar: SearchBar = { + let result = SearchBar() + result.tintColor = Colors.text + result.delegate = self + result.showsCancelButton = true + return result + }() + + internal lazy var tableView: UITableView = { + let result = UITableView(frame: .zero, style: .grouped) + result.rowHeight = UITableView.automaticDimension + result.estimatedRowHeight = 60 + result.separatorStyle = .none + result.keyboardDismissMode = .onDrag + result.register(EmptySearchResultCell.self, forCellReuseIdentifier: EmptySearchResultCell.reuseIdentifier) + result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier) + result.showsVerticalScrollIndicator = false + return result + }() + + // MARK: Dependencies + + var dbReadConnection: YapDatabaseConnection { + return OWSPrimaryStorage.shared().dbReadConnection + } + + // MARK: View Lifecycle + public override func viewDidLoad() { + super.viewDidLoad() + setUpGradientBackground() + + tableView.dataSource = self + tableView.delegate = self + view.addSubview(tableView) + tableView.pin(.leading, to: .leading, of: view) + tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing) + tableView.pin(.trailing, to: .trailing, of: view) + tableView.pin(.bottom, to: .bottom, of: view) + + navigationItem.hidesBackButton = true + setupNavigationBar() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + searchBar.becomeFirstResponder() + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + searchBar.resignFirstResponder() + } + + private func setupNavigationBar() { + // This is a workaround for a UI issue that the navigation bar can be a bit higher if + // the search bar is put directly to be the titleView. And this can cause the tableView + // in home screen doing a weird scrolling when going back to home screen. + let searchBarContainer = UIView() + searchBarContainer.layoutMargins = UIEdgeInsets.zero + searchBar.sizeToFit() + searchBar.layoutMargins = UIEdgeInsets.zero + searchBarContainer.set(.height, to: 44) + searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32) + searchBarContainer.addSubview(searchBar) + searchBar.autoPinEdgesToSuperviewMargins() + navigationItem.titleView = searchBarContainer + } + + private func reloadTableData() { + tableView.reloadData() + } + + // MARK: Update Search Results + + var refreshTimer: Timer? + + private func refreshSearchResults() { + + 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 self = self else { + return + } + + self.updateSearchResults(searchText: self.searchText) + self.refreshTimer = nil + } + } + + private func updateSearchResults(searchText rawSearchText: String) { + + let searchText = rawSearchText.stripped + guard searchText.count > 0 else { + searchResultSet = HomeScreenSearchResultSet.noteToSelfOnly + lastSearchText = nil + reloadTableData() + return + } + guard lastSearchText != searchText else { return } + + lastSearchText = searchText + + var searchResults: HomeScreenSearchResultSet? + self.dbReadConnection.asyncRead({[weak self] transaction in + guard let self = self else { return } + self.isLoading = true + // The max search result count is set according to the keyword length. This is just a workaround for performance issue. + // The longer and more accurate the keyword is, the less search results should there be. + searchResults = self.searcher.searchForHomeScreen(searchText: searchText, maxSearchResults: min(searchText.count * 50, 500), transaction: transaction) + }, completionBlock: { [weak self] in + AssertIsOnMainThread() + guard let self = self, let results = searchResults, self.lastSearchText == searchText else { return } + self.searchResultSet = results + self.isLoading = false + self.reloadTableData() + }) + } + + // MARK: Interaction + @objc func clearRecentSearchResults() { + recentSearchResults = [] + tableView.reloadSections([ SearchSection.recent.rawValue ], with: .top) + Storage.shared.clearRecentSearchResults() + } + +} + +// MARK: - UISearchBarDelegate +extension GlobalSearchViewController: UISearchBarDelegate { + public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + self.updateSearchText() + } + + public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + self.updateSearchText() + } + + public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + self.updateSearchText() + } + + public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.text = nil + searchBar.resignFirstResponder() + self.navigationController?.popViewController(animated: true) + } + + func updateSearchText() { + guard let searchText = searchBar.text?.ows_stripped() else { return } + self.searchText = searchText + } +} + +// MARK: - UITableViewDelegate & UITableViewDataSource +extension GlobalSearchViewController { + + // MARK: UITableViewDelegate + + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + guard let searchSection = SearchSection(rawValue: indexPath.section) else { return } + switch searchSection { + case .noResults: + SNLog("shouldn't be able to tap 'no results' section") + case .contacts: + let sectionResults = searchResultSet.conversations + guard let searchResult = sectionResults[safe: indexPath.row], let threadId = searchResult.thread.threadRecord.uniqueId, let thread = TSThread.fetch(uniqueId: threadId) else { return } + show(thread, highlightedMessageID: nil, animated: true) + case .messages: + let sectionResults = searchResultSet.messages + guard let searchResult = sectionResults[safe: indexPath.row], let threadId = searchResult.thread.threadRecord.uniqueId, let thread = TSThread.fetch(uniqueId: threadId) else { return } + show(thread, highlightedMessageID: searchResult.messageId, animated: true) + case .recent: + guard let threadId = recentSearchResults[safe: indexPath.row], let thread = TSThread.fetch(uniqueId: threadId) else { return } + show(thread, highlightedMessageID: nil, animated: true, isFromRecent: true) + } + } + + private func show(_ thread: TSThread, highlightedMessageID: String?, animated: Bool, isFromRecent: Bool = false) { + if let threadId = thread.uniqueId { + recentSearchResults = Array(Storage.shared.addSearchResults(threadID: threadId).reversed()) + } + + DispatchMainThreadSafe { + if let presentedVC = self.presentedViewController { + presentedVC.dismiss(animated: false, completion: nil) + } + let conversationVC = ConversationVC(thread: thread, focusedMessageID: highlightedMessageID) + var viewControllers = self.navigationController?.viewControllers + if isFromRecent, let index = viewControllers?.firstIndex(of: self) { viewControllers?.remove(at: index) } + viewControllers?.append(conversationVC) + self.navigationController?.setViewControllers(viewControllers!, animated: true) + } + } + + // MARK: UITableViewDataSource + + public func numberOfSections(in tableView: UITableView) -> Int { + return 4 + } + + public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + UIView() + } + + public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + .leastNonzeroMagnitude + } + + public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + guard nil != self.tableView(tableView, titleForHeaderInSection: section) else { + return .leastNonzeroMagnitude + } + return UITableView.automaticDimension + } + + public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let searchSection = SearchSection(rawValue: section) else { return nil } + + guard let title = self.tableView(tableView, titleForHeaderInSection: section) else { + return UIView() + } + + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.textColor = Colors.text + titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize) + + let container = UIView() + container.backgroundColor = Colors.cellBackground + container.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, left: Values.mediumSpacing, bottom: Values.smallSpacing, right: Values.mediumSpacing) + container.addSubview(titleLabel) + titleLabel.autoPinEdgesToSuperviewMargins() + + if searchSection == .recent { + let clearButton = UIButton() + clearButton.setTitle("Clear", for: .normal) + clearButton.setTitleColor(Colors.text, for: UIControl.State.normal) + clearButton.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize) + clearButton.addTarget(self, action: #selector(clearRecentSearchResults), for: .touchUpInside) + container.addSubview(clearButton) + clearButton.autoPinTrailingToSuperviewMargin() + clearButton.autoVCenterInSuperview() + } + + return container + } + + public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + guard let searchSection = SearchSection(rawValue: section) else { return nil } + + switch searchSection { + case .noResults: + return nil + case .contacts: + if searchResultSet.conversations.count > 0 { + return NSLocalizedString("SEARCH_SECTION_CONTACTS", comment: "") + } else { + return nil + } + case .messages: + if searchResultSet.messages.count > 0 { + return NSLocalizedString("SEARCH_SECTION_MESSAGES", comment: "") + } else { + return nil + } + case .recent: + if recentSearchResults.count > 0 && searchText.isEmpty && isRecentSearchResultsEnabled { + return NSLocalizedString("SEARCH_SECTION_RECENT", comment: "") + } else { + return nil + } + } + } + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard let searchSection = SearchSection(rawValue: section) else { return 0 } + switch searchSection { + case .noResults: + return (searchText.count > 0 && searchResultSet.isEmpty) ? 1 : 0 + case .contacts: + return searchResultSet.conversations.count + case .messages: + return searchResultSet.messages.count + case .recent: + return searchText.isEmpty && isRecentSearchResultsEnabled ? recentSearchResults.count : 0 + } + } + + public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UITableView.automaticDimension + } + + public 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, indexPath.row == 0 else { return UITableViewCell() } + cell.configure(isLoading: isLoading) + return cell + case .contacts: + let sectionResults = searchResultSet.conversations + let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell + cell.isShowingGlobalSearchResult = true + let searchResult = sectionResults[safe: indexPath.row] + cell.threadViewModel = searchResult?.thread + cell.configure(messageDate: searchResult?.messageDate, snippet: searchResult?.snippet, searchText: searchResultSet.searchText) + return cell + case .messages: + let sectionResults = searchResultSet.messages + let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell + cell.isShowingGlobalSearchResult = true + let searchResult = sectionResults[safe: indexPath.row] + cell.threadViewModel = searchResult?.thread + var message: TSMessage? = nil + if let messageId = searchResult?.messageId { message = TSMessage.fetch(uniqueId: messageId) } + cell.configure(messageDate: searchResult?.messageDate, snippet: searchResult?.snippet, searchText: searchResultSet.searchText, message: message) + return cell + case .recent: + let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell + cell.isShowingGlobalSearchResult = true + dbReadConnection.read { transaction in + guard let threadId = self.recentSearchResults[safe: indexPath.row], let thread = TSThread.fetch(uniqueId: threadId, transaction: transaction) else { return } + cell.threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) + } + cell.configureForRecent() + return cell + } + } +} diff --git a/Session/Home/GlobalSearch/Storage+RecentSearchResults.swift b/Session/Home/GlobalSearch/Storage+RecentSearchResults.swift new file mode 100644 index 000000000..9327b7abf --- /dev/null +++ b/Session/Home/GlobalSearch/Storage+RecentSearchResults.swift @@ -0,0 +1,32 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +extension Storage{ + + private static let recentSearchResultDatabaseCollection = "RecentSearchResultDatabaseCollection" + private static let recentSearchResultKey = "RecentSearchResult" + + public func getRecentSearchResults() -> [String] { + var result: [String]? + Storage.read { transaction in + result = transaction.object(forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection) as? [String] + } + return result ?? [] + } + + public func clearRecentSearchResults() { + Storage.write { transaction in + transaction.removeObject(forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection) + } + } + + public func addSearchResults(threadID: String) -> [String] { + var recentSearchResults = getRecentSearchResults() + if recentSearchResults.count > 20 { recentSearchResults.remove(at: 0) } // Limit the size of the collection to 20 + if let index = recentSearchResults.firstIndex(of: threadID) { recentSearchResults.remove(at: index) } + recentSearchResults.append(threadID) + Storage.write { transaction in + transaction.setObject(recentSearchResults, forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection) + } + return recentSearchResults + } +} diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index b2d643011..1d2a4aee8 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -2,7 +2,6 @@ // See https://github.com/yapstudios/YapDatabase/wiki/LongLivedReadTransactions and // https://github.com/yapstudios/YapDatabase/wiki/YapDatabaseModifiedNotification for // more information on database handling. - final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate { private var threads: YapDatabaseViewMappings! private var threadViewModelCache: [String:ThreadViewModel] = [:] // Thread ID to ThreadViewModel @@ -89,7 +88,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv setUpNavBarStyle() } updateNavBarButtons() - setNavBarTitle(NSLocalizedString("vc_home_title", comment: "")) + setUpNavBarSessionHeading() // Recovery phrase reminder let hasViewedSeed = UserDefaults.standard[.hasViewedSeed] if !hasViewedSeed { @@ -266,6 +265,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv } private func updateNavBarButtons() { + // Profile picture view let profilePictureSize = Values.verySmallProfilePictureSize let profilePictureView = ProfilePictureView() profilePictureView.accessibilityLabel = "Settings button" @@ -276,32 +276,27 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv profilePictureView.set(.height, to: profilePictureSize) let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings)) profilePictureView.addGestureRecognizer(tapGestureRecognizer) + // Path status indicator + let pathStatusView = PathStatusView() + pathStatusView.accessibilityLabel = "Current onion routing path indicator" + pathStatusView.set(.width, to: PathStatusView.size) + pathStatusView.set(.height, to: PathStatusView.size) + // Container view let profilePictureViewContainer = UIView() profilePictureViewContainer.accessibilityLabel = "Settings button" profilePictureViewContainer.addSubview(profilePictureView) - profilePictureView.pin(.leading, to: .leading, of: profilePictureViewContainer, withInset: 4) - profilePictureView.pin(.top, to: .top, of: profilePictureViewContainer) - profilePictureView.pin(.trailing, to: .trailing, of: profilePictureViewContainer) - profilePictureView.pin(.bottom, to: .bottom, of: profilePictureViewContainer) + profilePictureView.autoPinEdgesToSuperviewEdges() + profilePictureViewContainer.addSubview(pathStatusView) + pathStatusView.pin(.trailing, to: .trailing, of: profilePictureViewContainer) + pathStatusView.pin(.bottom, to: .bottom, of: profilePictureViewContainer) + // Left bar button item let leftBarButtonItem = UIBarButtonItem(customView: profilePictureViewContainer) leftBarButtonItem.accessibilityLabel = "Settings button" leftBarButtonItem.isAccessibilityElement = true navigationItem.leftBarButtonItem = leftBarButtonItem - let pathStatusViewContainer = UIView() - pathStatusViewContainer.accessibilityLabel = "Current onion routing path button" - let pathStatusViewContainerSize = Values.verySmallProfilePictureSize // Match the profile picture view - pathStatusViewContainer.set(.width, to: pathStatusViewContainerSize) - pathStatusViewContainer.set(.height, to: pathStatusViewContainerSize) - let pathStatusView = PathStatusView() - pathStatusView.accessibilityLabel = "Current onion routing path button" - pathStatusView.set(.width, to: PathStatusView.size) - pathStatusView.set(.height, to: PathStatusView.size) - pathStatusViewContainer.addSubview(pathStatusView) - pathStatusView.center(.horizontal, in: pathStatusViewContainer) - pathStatusView.center(.vertical, in: pathStatusViewContainer) - pathStatusViewContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showPath))) - let rightBarButtonItem = UIBarButtonItem(customView: pathStatusViewContainer) - rightBarButtonItem.accessibilityLabel = "Current onion routing path button" + // Right bar button item - search button + let rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .search, target: self, action: #selector(showSearchUI)) + rightBarButtonItem.accessibilityLabel = "Search button" rightBarButtonItem.isAccessibilityElement = true navigationItem.rightBarButtonItem = rightBarButtonItem } @@ -418,10 +413,12 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv present(navigationController, animated: true, completion: nil) } - @objc private func showPath() { - let pathVC = PathVC() - let navigationController = OWSNavigationController(rootViewController: pathVC) - present(navigationController, animated: true, completion: nil) + @objc private func showSearchUI() { + if let presentedVC = self.presentedViewController { + presentedVC.dismiss(animated: false, completion: nil) + } + let searchController = GlobalSearchViewController() + self.navigationController?.setViewControllers([ self, searchController ], animated: true) } @objc func joinOpenGroup() { diff --git a/Session/Meta/Images.xcassets/Session/SessionHeading.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/SessionHeading.imageset/Contents.json new file mode 100644 index 000000000..d360cfe11 --- /dev/null +++ b/Session/Meta/Images.xcassets/Session/SessionHeading.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "heading.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Session/SessionHeading.imageset/heading.pdf b/Session/Meta/Images.xcassets/Session/SessionHeading.imageset/heading.pdf new file mode 100644 index 000000000..982e95d6f Binary files /dev/null and b/Session/Meta/Images.xcassets/Session/SessionHeading.imageset/heading.pdf differ diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index a58f90996..41b55e8bd 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -579,3 +579,7 @@ "light_mode_theme" = "Light"; "PIN_BUTTON_TEXT" = "Pin"; "UNPIN_BUTTON_TEXT" = "Unpin"; +"SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; +"SEARCH_SECTION_MESSAGES" = "Messages"; +"SEARCH_SECTION_RECENT" = "Recent"; +"RECENT_SEARCH_LAST_MESSAGE_DATETIME" = "last message: %@"; diff --git a/Session/Path/PathVC.swift b/Session/Path/PathVC.swift index 154eeed86..703f24e20 100644 --- a/Session/Path/PathVC.swift +++ b/Session/Path/PathVC.swift @@ -40,10 +40,6 @@ final class PathVC : BaseVC { private func setUpNavBar() { setUpNavBarStyle() setNavBarTitle(NSLocalizedString("vc_path_title", comment: "")) - // Set up close button - let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close)) - closeButton.tintColor = Colors.text - navigationItem.leftBarButtonItem = closeButton } private func setUpViewHierarchy() { @@ -167,10 +163,6 @@ final class PathVC : BaseVC { } // MARK: Interaction - @objc private func close() { - dismiss(animated: true, completion: nil) - } - @objc private func learnMore() { let urlAsString = "https://getsession.org/faq/#onion-routing" let url = URL(string: urlAsString)! diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index 64046bb71..973d5b583 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -244,7 +244,21 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { button.set(.height, to: SettingsVC.buttonHeight) return button } + + let pathButton = getSettingButton(withTitle: NSLocalizedString("vc_path_title", comment: ""), color: Colors.text, action: #selector(showPath)) + let pathStatusView = PathStatusView() + pathStatusView.set(.width, to: PathStatusView.size) + pathStatusView.set(.height, to: PathStatusView.size) + + pathButton.addSubview(pathStatusView) + pathStatusView.pin(.leading, to: .trailing, of: pathButton.titleLabel!, withInset: Values.smallSpacing) + pathStatusView.autoVCenterInSuperview() + + pathButton.titleEdgeInsets = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: Values.smallSpacing) + return [ + getSeparator(), + pathButton, getSeparator(), getSettingButton(withTitle: NSLocalizedString("vc_settings_privacy_button_title", comment: ""), color: Colors.text, action: #selector(showPrivacySettings)), getSeparator(), @@ -480,6 +494,11 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { navigationController!.present(shareVC, animated: true, completion: nil) } + @objc private func showPath() { + let pathVC = PathVC() + navigationController!.pushViewController(pathVC, animated: true) + } + @objc private func showPrivacySettings() { let privacySettingsVC = PrivacySettingsTableViewController() navigationController!.pushViewController(privacySettingsVC, animated: true) diff --git a/Session/Shared/BaseVC.swift b/Session/Shared/BaseVC.swift index 83f9c9262..1f2ad64d9 100644 --- a/Session/Shared/BaseVC.swift +++ b/Session/Shared/BaseVC.swift @@ -75,6 +75,16 @@ class BaseVC : UIViewController { crossfadeLabel.pin(to: container) navigationItem.titleView = container } + + internal func setUpNavBarSessionHeading() { + let headingImageView = UIImageView() + headingImageView.tintColor = Colors.sessionHeading + headingImageView.image = UIImage(named: "SessionHeading")?.withRenderingMode(.alwaysTemplate) + headingImageView.contentMode = .scaleAspectFit + headingImageView.set(.width, to: 150) + headingImageView.set(.height, to: Values.mediumFontSize) + navigationItem.titleView = headingImageView + } internal func setUpNavBarSessionIcon() { let logoImageView = UIImageView() diff --git a/Session/Shared/ConversationCell.swift b/Session/Shared/ConversationCell.swift index 2a7edf38a..9969fe8fc 100644 --- a/Session/Shared/ConversationCell.swift +++ b/Session/Shared/ConversationCell.swift @@ -2,7 +2,12 @@ import UIKit import SessionUIKit final class ConversationCell : UITableViewCell { - var threadViewModel: ThreadViewModel! { didSet { update() } } + var isShowingGlobalSearchResult = false + var threadViewModel: ThreadViewModel! { + didSet { + isShowingGlobalSearchResult ? updateForSearchResult() : update() + } + } static let reuseIdentifier = "ConversationCell" @@ -96,6 +101,22 @@ final class ConversationCell : UITableViewCell { return result }() + private lazy var topLabelStackView: UIStackView = { + let result = UIStackView() + result.axis = .horizontal + result.alignment = .center + result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer + return result + }() + + private lazy var bottomLabelStackView: UIStackView = { + let result = UIStackView() + result.axis = .horizontal + result.alignment = .center + result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer + return result + }() + // MARK: Settings private static let unreadCountViewSize: CGFloat = 20 private static let statusIndicatorSize: CGFloat = 14 @@ -135,21 +156,20 @@ final class ConversationCell : UITableViewCell { hasMentionLabel.pin(to: hasMentionView) // Label stack view let topLabelSpacer = UIView.hStretchingSpacer() - let topLabelStackView = UIStackView(arrangedSubviews: [ displayNameLabel, isPinnedIcon, unreadCountView, hasMentionView, topLabelSpacer, timestampLabel ]) - topLabelStackView.axis = .horizontal - topLabelStackView.alignment = .center - topLabelStackView.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer + [ displayNameLabel, isPinnedIcon, unreadCountView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in + topLabelStackView.addArrangedSubview(view) + } let snippetLabelContainer = UIView() snippetLabelContainer.addSubview(snippetLabel) snippetLabelContainer.addSubview(typingIndicatorView) let bottomLabelSpacer = UIView.hStretchingSpacer() - let bottomLabelStackView = UIStackView(arrangedSubviews: [ snippetLabelContainer, bottomLabelSpacer, statusIndicatorView ]) - bottomLabelStackView.axis = .horizontal - bottomLabelStackView.alignment = .center - bottomLabelStackView.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer - let labelContainerView = UIView() - labelContainerView.addSubview(topLabelStackView) - labelContainerView.addSubview(bottomLabelStackView) + [ snippetLabelContainer, bottomLabelSpacer, statusIndicatorView ].forEach{ view in + bottomLabelStackView.addArrangedSubview(view) + } + let labelContainerView = UIStackView(arrangedSubviews: [ topLabelStackView, bottomLabelStackView ]) + labelContainerView.axis = .vertical + labelContainerView.alignment = .leading + labelContainerView.spacing = 6 // Main stack view let stackView = UIStackView(arrangedSubviews: [ accentLineView, profilePictureView, labelContainerView ]) stackView.axis = .horizontal @@ -172,16 +192,6 @@ final class ConversationCell : UITableViewCell { snippetLabel.pin(to: snippetLabelContainer) typingIndicatorView.pin(.leading, to: .leading, of: snippetLabelContainer) typingIndicatorView.centerYAnchor.constraint(equalTo: snippetLabel.centerYAnchor).isActive = true - // HACK: Not using a stack view for this is part of a workaround for a weird layout bug - topLabelStackView.pin(.leading, to: .leading, of: labelContainerView) - topLabelStackView.pin(.top, to: .top, of: labelContainerView, withInset: 12) - topLabelStackView.pin(.trailing, to: .trailing, of: labelContainerView) - bottomLabelStackView.pin(.leading, to: .leading, of: labelContainerView) - bottomLabelStackView.pin(.top, to: .bottom, of: topLabelStackView, withInset: 6) - labelContainerView.pin(.bottom, to: .bottom, of: bottomLabelStackView, withInset: 12) - // HACK: The two lines below are part of a workaround for a weird layout bug - labelContainerView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - Values.mediumSpacing - profilePictureViewSize - Values.mediumSpacing - Values.mediumSpacing) - labelContainerView.set(.height, to: cellHeight) stackView.pin(.leading, to: .leading, of: contentView) stackView.pin(.top, to: .top, of: contentView) // HACK: The two lines below are part of a workaround for a weird layout bug @@ -189,6 +199,79 @@ final class ConversationCell : UITableViewCell { stackView.set(.height, to: cellHeight) } + // MARK: Updating for search results + private func updateForSearchResult() { + AssertIsOnMainThread() + guard let thread = threadViewModel?.threadRecord else { return } + profilePictureView.update(for: thread) + isPinnedIcon.isHidden = true + unreadCountView.isHidden = true + hasMentionView.isHidden = true + } + + public func configureForRecent() { + displayNameLabel.attributedText = NSMutableAttributedString(string: getDisplayName(), attributes: [.foregroundColor:Colors.text]) + bottomLabelStackView.isHidden = false + let snippet = String(format: NSLocalizedString("RECENT_SEARCH_LAST_MESSAGE_DATETIME", comment: ""), DateUtil.formatDate(forDisplay: threadViewModel.lastMessageDate)) + snippetLabel.attributedText = NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text.withAlphaComponent(Values.lowOpacity)]) + timestampLabel.isHidden = true + } + + public func configure(messageDate: Date?, snippet: String?, searchText: String, message: TSMessage? = nil) { + let normalizedSearchText = searchText.lowercased() + if let messageDate = messageDate, let snippet = snippet { + // Message + displayNameLabel.attributedText = NSMutableAttributedString(string: getDisplayName(), attributes: [.foregroundColor:Colors.text]) + timestampLabel.isHidden = false + timestampLabel.text = DateUtil.formatDate(forDisplay: messageDate) + bottomLabelStackView.isHidden = false + var rawSnippet = snippet + if let message = message, let name = getMessageAuthorName(message: message) { + rawSnippet = "\(name): \(snippet)" + } + snippetLabel.attributedText = getHighlightedSnippet(snippet: rawSnippet, searchText: normalizedSearchText, fontSize: Values.smallFontSize) + } else { + // Contact + if threadViewModel.isGroupThread, let thread = threadViewModel.threadRecord as? TSGroupThread { + displayNameLabel.attributedText = getHighlightedSnippet(snippet: getDisplayName(), searchText: normalizedSearchText, fontSize: Values.mediumFontSize) + bottomLabelStackView.isHidden = false + let context: Contact.Context = thread.isOpenGroup ? .openGroup : .regular + var rawSnippet: String = "" + thread.groupModel.groupMemberIds.forEach{ id in + if let displayName = Storage.shared.getContact(with: id)?.displayName(for: context) { + if !rawSnippet.isEmpty { + rawSnippet += ", \(displayName)" + } + if displayName.lowercased().contains(normalizedSearchText) { + rawSnippet = displayName + } + } + } + snippetLabel.attributedText = getHighlightedSnippet(snippet: rawSnippet, searchText: normalizedSearchText, fontSize: Values.smallFontSize) + } else { + displayNameLabel.attributedText = getHighlightedSnippet(snippet: getDisplayNameForSearch(threadViewModel.contactSessionID!), searchText: normalizedSearchText, fontSize: Values.mediumFontSize) + bottomLabelStackView.isHidden = true + } + timestampLabel.isHidden = true + } + } + + private func getHighlightedSnippet(snippet: String, searchText: String, fontSize: CGFloat) -> NSMutableAttributedString { + guard snippet != NSLocalizedString("NOTE_TO_SELF", comment: "") else { + return NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text]) + } + + let result = NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text.withAlphaComponent(Values.lowOpacity)]) + let normalizedSnippet = snippet.lowercased() as NSString + + guard normalizedSnippet.contains(searchText) else { return result } + + let range = normalizedSnippet.range(of: searchText) + result.addAttribute(.foregroundColor, value: Colors.text, range: range) + result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: range) + return result + } + // MARK: Updating private func update() { AssertIsOnMainThread() @@ -246,6 +329,27 @@ final class ConversationCell : UITableViewCell { } } + private func getMessageAuthorName(message: TSMessage) -> String? { + guard threadViewModel.isGroupThread else { return nil } + if let incomingMessage = message as? TSIncomingMessage { + return Storage.shared.getContact(with: incomingMessage.authorId)?.displayName(for: .regular) ?? "Anonymous" + } + return nil + } + + private func getDisplayNameForSearch(_ sessionID: String) -> String { + if threadViewModel.threadRecord.isNoteToSelf() { + return NSLocalizedString("NOTE_TO_SELF", comment: "") + } else { + var result = sessionID + if let contact = Storage.shared.getContact(with: sessionID), let name = contact.name { + result = name + if let nickname = contact.nickname { result += "(\(nickname))"} + } + return result + } + } + private func getDisplayName() -> String { if threadViewModel.isGroupThread { if threadViewModel.name.isEmpty { @@ -275,9 +379,12 @@ final class ConversationCell : UITableViewCell { result.append(imageString) result.append(NSAttributedString(string: " ", attributes: [ .font : UIFont.ows_elegantIconsFont(10), .foregroundColor : Colors.unimportant ])) } + let font = threadViewModel.hasUnreadMessages ? UIFont.boldSystemFont(ofSize: Values.smallFontSize) : UIFont.systemFont(ofSize: Values.smallFontSize) + if threadViewModel.isGroupThread, let message = threadViewModel.lastMessageForInbox as? TSMessage, let name = getMessageAuthorName(message: message) { + result.append(NSAttributedString(string: "\(name): ", attributes: [ .font : font, .foregroundColor : Colors.text ])) + } if let rawSnippet = threadViewModel.lastMessageText { let snippet = MentionUtilities.highlightMentions(in: rawSnippet, threadID: threadViewModel.threadRecord.uniqueId!) - let font = threadViewModel.hasUnreadMessages ? UIFont.boldSystemFont(ofSize: Values.smallFontSize) : UIFont.systemFont(ofSize: Values.smallFontSize) result.append(NSAttributedString(string: snippet, attributes: [ .font : font, .foregroundColor : Colors.text ])) } return result diff --git a/SessionMessagingKit/Threads/TSContactThread.m b/SessionMessagingKit/Threads/TSContactThread.m index 1024389f7..c69994802 100644 --- a/SessionMessagingKit/Threads/TSContactThread.m +++ b/SessionMessagingKit/Threads/TSContactThread.m @@ -69,6 +69,13 @@ NSString *const TSContactThreadPrefix = @"c"; return [contact displayNameFor:SNContactContextRegular] ?: sessionID; } +- (NSString *)nameWithTransaction:(YapDatabaseReadTransaction *)transaction +{ + NSString *sessionID = self.contactSessionID; + SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID using:transaction]; + return [contact displayNameFor:SNContactContextRegular] ?: sessionID; +} + + (NSString *)threadIDFromContactSessionID:(NSString *)contactSessionID { return [TSContactThreadPrefix stringByAppendingString:contactSessionID]; } diff --git a/SessionMessagingKit/Threads/TSGroupThread.m b/SessionMessagingKit/Threads/TSGroupThread.m index 382f6ed5e..61b2e706e 100644 --- a/SessionMessagingKit/Threads/TSGroupThread.m +++ b/SessionMessagingKit/Threads/TSGroupThread.m @@ -192,6 +192,11 @@ NSString *const TSGroupThread_NotificationKey_UniqueId = @"TSGroupThread_Notific return self.groupModel.groupName ?: self.class.defaultGroupName; } +- (NSString *)nameWithTransaction:(YapDatabaseReadTransaction *)transaction +{ + return [self name]; +} + + (NSString *)defaultGroupName { return @"Group"; diff --git a/SessionMessagingKit/Threads/TSThread.h b/SessionMessagingKit/Threads/TSThread.h index c2449397a..dcf5346c3 100644 --- a/SessionMessagingKit/Threads/TSThread.h +++ b/SessionMessagingKit/Threads/TSThread.h @@ -38,6 +38,8 @@ BOOL IsNoteToSelfEnabled(void); */ - (NSString *)name; +- (NSString *)nameWithTransaction:(YapDatabaseReadTransaction *)transaction; + /** * @returns recipientId for each recipient in the thread */ diff --git a/SessionMessagingKit/Threads/TSThread.m b/SessionMessagingKit/Threads/TSThread.m index 6283a324d..94402dc2f 100644 --- a/SessionMessagingKit/Threads/TSThread.m +++ b/SessionMessagingKit/Threads/TSThread.m @@ -148,6 +148,11 @@ BOOL IsNoteToSelfEnabled(void) return nil; } +- (NSString *)nameWithTransaction:(YapDatabaseReadTransaction *)transaction +{ + return nil; +} + - (NSArray *)recipientIdentifiers { return @[]; diff --git a/SessionMessagingKit/Utilities/FullTextSearchFinder.swift b/SessionMessagingKit/Utilities/FullTextSearchFinder.swift index 96b112024..0320aeaf2 100644 --- a/SessionMessagingKit/Utilities/FullTextSearchFinder.swift +++ b/SessionMessagingKit/Utilities/FullTextSearchFinder.swift @@ -85,18 +85,19 @@ public class FullTextSearchFinder: NSObject { return query } - public func enumerateObjects(searchText: String, transaction: YapDatabaseReadTransaction, block: @escaping (Any, String) -> Void) { + public func enumerateObjects(searchText: String, maxSearchResults: Int? = nil, transaction: YapDatabaseReadTransaction, block: @escaping (Any, String) -> Void) { guard let ext: YapDatabaseFullTextSearchTransaction = ext(transaction: transaction) else { return } let query = FullTextSearchFinder.query(searchText: searchText) - let maxSearchResults = 500 + let maxSearchResults = maxSearchResults ?? 500 var searchResultCount = 0 let snippetOptions = YapDatabaseFullTextSearchSnippetOptions() snippetOptions.startMatchText = "" snippetOptions.endMatchText = "" + snippetOptions.numberOfTokens = 5 ext.enumerateKeysAndObjects(matching: query, with: snippetOptions) { (snippet: String, _: String, _: String, object: Any, stop: UnsafeMutablePointer) in guard searchResultCount < maxSearchResults else { stop.pointee = true @@ -177,8 +178,12 @@ public class FullTextSearchFinder: NSObject { } private static let recipientIndexer: SearchIndexer = SearchIndexer { (recipientId: String, transaction: YapDatabaseReadTransaction) in - let displayName = Storage.shared.getContact(with: recipientId)?.displayName(for: Contact.Context.regular) ?? recipientId - return "\(recipientId) \(displayName)" + var result = "\(recipientId)" + if let contact = Storage.shared.getContact(with: recipientId) { + if let name = contact.name { result += " \(name)" } + if let nickname = contact.nickname { result += " \(nickname)" } + } + return result } private static let messageIndexer: SearchIndexer = SearchIndexer { (message: TSMessage, transaction: YapDatabaseReadTransaction) in @@ -241,6 +246,6 @@ public class FullTextSearchFinder: NSObject { options: nil, handler: handler, ftsVersion: YapDatabaseFullTextSearchFTS5Version, - versionTag: "1") + versionTag: "2") } } diff --git a/SessionUIKit/Components/SearchBar.swift b/SessionUIKit/Components/SearchBar.swift index 9897546d7..e4748b0ba 100644 --- a/SessionUIKit/Components/SearchBar.swift +++ b/SessionUIKit/Components/SearchBar.swift @@ -29,16 +29,8 @@ public final class SearchBar : UISearchBar { searchTextField.backgroundColor = Colors.searchBarBackground // The search bar background color searchTextField.textColor = Colors.text searchTextField.attributedPlaceholder = NSAttributedString(string: NSLocalizedString("Search", comment: ""), attributes: [ .foregroundColor : Colors.searchBarPlaceholder ]) - searchTextField.keyboardAppearance = .dark setPositionAdjustment(UIOffset(horizontal: 4, vertical: 0), for: UISearchBar.Icon.search) searchTextPositionAdjustment = UIOffset(horizontal: 2, vertical: 0) setPositionAdjustment(UIOffset(horizontal: -4, vertical: 0), for: UISearchBar.Icon.clear) - searchTextField.removeConstraints(searchTextField.constraints) - searchTextField.pin(.leading, to: .leading, of: searchTextField.superview!, withInset: Values.mediumSpacing + 3) - searchTextField.pin(.top, to: .top, of: searchTextField.superview!, withInset: 10) - searchTextField.superview!.pin(.trailing, to: .trailing, of: searchTextField, withInset: Values.mediumSpacing + 3) - searchTextField.superview!.pin(.bottom, to: .bottom, of: searchTextField, withInset: 10) - searchTextField.set(.height, to: Values.searchBarHeight) - searchTextField.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing) } } diff --git a/SessionUIKit/Style Guide/Colors.swift b/SessionUIKit/Style Guide/Colors.swift index 1a150ebc9..35f003175 100644 --- a/SessionUIKit/Style Guide/Colors.swift +++ b/SessionUIKit/Style Guide/Colors.swift @@ -42,4 +42,5 @@ public final class Colors : NSObject { @objc public static var pnOptionBorder: UIColor { UIColor(named: "session_pn_option_border")! } @objc public static var pathsBuilding: UIColor { UIColor(named: "session_paths_building")! } @objc public static var pinIcon: UIColor { UIColor(named: "session_pin_icon")! } + @objc public static var sessionHeading: UIColor { UIColor(named: "session_heading")! } } diff --git a/SessionUIKit/Style Guide/Colors.xcassets/session_cell_background.colorset/Contents.json b/SessionUIKit/Style Guide/Colors.xcassets/session_cell_background.colorset/Contents.json index 9f3131695..9cece1931 100644 --- a/SessionUIKit/Style Guide/Colors.xcassets/session_cell_background.colorset/Contents.json +++ b/SessionUIKit/Style Guide/Colors.xcassets/session_cell_background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xFC", - "green" : "0xFC", - "red" : "0xFC" + "blue" : "252", + "green" : "252", + "red" : "252" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x1B", - "green" : "0x1B", - "red" : "0x1B" + "blue" : "22", + "green" : "22", + "red" : "22" } }, "idiom" : "universal" diff --git a/SessionUIKit/Style Guide/Colors.xcassets/session_cell_pinned.colorset/Contents.json b/SessionUIKit/Style Guide/Colors.xcassets/session_cell_pinned.colorset/Contents.json index a5a409cf4..e871732bc 100644 --- a/SessionUIKit/Style Guide/Colors.xcassets/session_cell_pinned.colorset/Contents.json +++ b/SessionUIKit/Style Guide/Colors.xcassets/session_cell_pinned.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xF0", - "green" : "0xF0", - "red" : "0xF0" + "blue" : "247", + "green" : "247", + "red" : "247" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x40", - "green" : "0x40", - "red" : "0x40" + "blue" : "28", + "green" : "28", + "red" : "28" } }, "idiom" : "universal" diff --git a/SessionUIKit/Style Guide/Colors.xcassets/session_heading.colorset/Contents.json b/SessionUIKit/Style Guide/Colors.xcassets/session_heading.colorset/Contents.json new file mode 100644 index 000000000..4da0447c6 --- /dev/null +++ b/SessionUIKit/Style Guide/Colors.xcassets/session_heading.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x82", + "green" : "0xF7", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SessionUIKit/Style Guide/Colors.xcassets/session_navigation_bar_background.colorset/Contents.json b/SessionUIKit/Style Guide/Colors.xcassets/session_navigation_bar_background.colorset/Contents.json index 0ecfa5c92..9cece1931 100644 --- a/SessionUIKit/Style Guide/Colors.xcassets/session_navigation_bar_background.colorset/Contents.json +++ b/SessionUIKit/Style Guide/Colors.xcassets/session_navigation_bar_background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xFC", - "green" : "0xFC", - "red" : "0xFC" + "blue" : "252", + "green" : "252", + "red" : "252" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x16", - "green" : "0x16", - "red" : "0x16" + "blue" : "22", + "green" : "22", + "red" : "22" } }, "idiom" : "universal" diff --git a/SignalUtilitiesKit/Messaging/FullTextSearcher.swift b/SignalUtilitiesKit/Messaging/FullTextSearcher.swift index 438a46212..236ad7e44 100644 --- a/SignalUtilitiesKit/Messaging/FullTextSearcher.swift +++ b/SignalUtilitiesKit/Messaging/FullTextSearcher.swift @@ -65,6 +65,20 @@ public class HomeScreenSearchResultSet: NSObject { public class var empty: HomeScreenSearchResultSet { return HomeScreenSearchResultSet(searchText: "", conversations: [], messages: []) } + + public class var noteToSelfOnly: HomeScreenSearchResultSet { + var conversations: [ConversationSearchResult] = [] + Storage.read { transaction in + if let thread = TSContactThread.getWithContactSessionID(getUserHexEncodedPublicKey(), transaction: transaction) { + let threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) + let sortKey = ConversationSortKey(creationDate: thread.creationDate, + lastMessageReceivedAtDate: thread.lastInteractionForInbox(transaction: transaction)?.receivedAtDate()) + let searchResult = ConversationSearchResult(thread: threadViewModel, sortKey: sortKey) + conversations.append(searchResult) + } + } + return HomeScreenSearchResultSet(searchText: "", conversations: conversations, messages: []) + } public var isEmpty: Bool { return conversations.isEmpty && messages.isEmpty @@ -227,6 +241,7 @@ public class FullTextSearcher: NSObject { } public func searchForHomeScreen(searchText: String, + maxSearchResults: Int? = nil, transaction: YapDatabaseReadTransaction) -> HomeScreenSearchResultSet { var conversations: [ConversationSearchResult] = [] @@ -234,7 +249,7 @@ public class FullTextSearcher: NSObject { var existingConversationRecipientIds: Set = Set() - self.finder.enumerateObjects(searchText: searchText, transaction: transaction) { (match: Any, snippet: String?) in + self.finder.enumerateObjects(searchText: searchText, maxSearchResults: maxSearchResults, transaction: transaction) { (match: Any, snippet: String?) in if let thread = match as? TSThread { let threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) diff --git a/SignalUtilitiesKit/Messaging/ThreadViewModel.swift b/SignalUtilitiesKit/Messaging/ThreadViewModel.swift index 2e6f9bab1..daf54062c 100644 --- a/SignalUtilitiesKit/Messaging/ThreadViewModel.swift +++ b/SignalUtilitiesKit/Messaging/ThreadViewModel.swift @@ -30,7 +30,7 @@ public class ThreadViewModel: NSObject { self.threadRecord = thread self.isGroupThread = thread.isGroupThread() - self.name = thread.name() + self.name = thread.name(with: transaction) self.isMuted = thread.isMuted self.isPinned = thread.isPinned self.lastMessageText = thread.lastMessageText(transaction: transaction) diff --git a/SignalUtilitiesKit/Utilities/UIView+OWS.swift b/SignalUtilitiesKit/Utilities/UIView+OWS.swift index 12ff3354b..2e02932ef 100644 --- a/SignalUtilitiesKit/Utilities/UIView+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIView+OWS.swift @@ -120,11 +120,11 @@ public extension UIView { return constraints } - func setShadow(radius: CGFloat = 2.0, opacity: CGFloat = 0.66, offset: CGPoint = .zero, color: CGColor = UIColor.black.cgColor) { - layer.shadowColor = UIColor.black.cgColor - layer.shadowRadius = 2.0 - layer.shadowOpacity = 0.66 - layer.shadowOffset = .zero + func setShadow(radius: CGFloat = 2.0, opacity: Float = 0.66, offset: CGSize = .zero, color: CGColor = UIColor.black.cgColor) { + layer.shadowColor = color + layer.shadowRadius = radius + layer.shadowOpacity = opacity + layer.shadowOffset = offset } }