From 71dd4eb151c7d355d8fa2a54bf4def365b749aef Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Mon, 11 Feb 2019 09:49:26 -0700 Subject: [PATCH] in-conversation search - use MediaTime for computing benchmarks --- Signal.xcodeproj/project.pbxproj | 12 +- .../ic_chevron_down.imageset/Contents.json | 23 ++ .../chevron-down-24@1x.png | Bin 0 -> 234 bytes .../chevron-down-24@2x.png | Bin 0 -> 359 bytes .../chevron-down-24@3x.png | Bin 0 -> 521 bytes .../ic_chevron_up.imageset/Contents.json | 23 ++ .../chevron-up-24@1x.png | Bin 0 -> 235 bytes .../chevron-up-24@2x.png | Bin 0 -> 351 bytes .../chevron-up-24@3x.png | Bin 0 -> 516 bytes Signal/src/ConversationSearch.swift | 289 ++++++++++++++++++ .../Cells/OWSMessageBubbleView.h | 2 + .../Cells/OWSMessageBubbleView.m | 26 +- .../ConversationMessageMapping.swift | 2 +- .../ConversationViewController.m | 125 +++++++- .../ConversationView/ConversationViewModel.h | 1 + .../ConversationView/ConversationViewModel.m | 28 +- .../ConversationSearchViewController.swift | 4 +- .../MessageDetailViewController.swift | 4 + .../NewContactThreadViewController.m | 12 +- .../ViewControllers/NewGroupViewController.h | 5 +- .../OWSConversationSettingsViewController.m | 34 ++- .../OWSConversationSettingsViewDelegate.h | 6 +- .../UpdateGroupViewController.m | 5 +- Signal/test/util/SearcherTest.swift | 10 +- .../translations/en.lproj/Localizable.strings | 12 + .../SelectThreadViewController.m | 6 +- SignalMessaging/Views/ContactsViewHelper.m | 7 +- SignalMessaging/Views/OWSSearchBar.h | 4 +- SignalMessaging/Views/OWSSearchBar.m | 23 +- SignalMessaging/categories/UIView+OWS.swift | 4 +- SignalMessaging/utils/Bench.swift | 13 +- ...nSearcher.swift => FullTextSearcher.swift} | 87 +++++- 32 files changed, 697 insertions(+), 70 deletions(-) create mode 100644 Signal/Images.xcassets/ic_chevron_down.imageset/Contents.json create mode 100644 Signal/Images.xcassets/ic_chevron_down.imageset/chevron-down-24@1x.png create mode 100644 Signal/Images.xcassets/ic_chevron_down.imageset/chevron-down-24@2x.png create mode 100644 Signal/Images.xcassets/ic_chevron_down.imageset/chevron-down-24@3x.png create mode 100644 Signal/Images.xcassets/ic_chevron_up.imageset/Contents.json create mode 100644 Signal/Images.xcassets/ic_chevron_up.imageset/chevron-up-24@1x.png create mode 100644 Signal/Images.xcassets/ic_chevron_up.imageset/chevron-up-24@2x.png create mode 100644 Signal/Images.xcassets/ic_chevron_up.imageset/chevron-up-24@3x.png create mode 100644 Signal/src/ConversationSearch.swift rename SignalMessaging/utils/{ConversationSearcher.swift => FullTextSearcher.swift} (84%) diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 69f3722b8..3add7cb25 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -338,7 +338,7 @@ 45194F941FD7216000333B2C /* TSUnreadIndicatorInteraction.h in Headers */ = {isa = PBXBuildFile; fileRef = 34C42D641F4734ED0072EC04 /* TSUnreadIndicatorInteraction.h */; settings = {ATTRIBUTES = (Public, ); }; }; 45194F951FD7216600333B2C /* TSUnreadIndicatorInteraction.m in Sources */ = {isa = PBXBuildFile; fileRef = 34C42D651F4734ED0072EC04 /* TSUnreadIndicatorInteraction.m */; }; 451A13B11E13DED2000A50FD /* AppNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451A13B01E13DED2000A50FD /* AppNotifications.swift */; }; - 451F8A341FD710C3005CB9DA /* ConversationSearcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451777C71FD61554001225FF /* ConversationSearcher.swift */; }; + 451F8A341FD710C3005CB9DA /* FullTextSearcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451777C71FD61554001225FF /* FullTextSearcher.swift */; }; 451F8A351FD710DE005CB9DA /* Searcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45360B8C1F9521F800FA666C /* Searcher.swift */; }; 451F8A3B1FD71297005CB9DA /* UIUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = B97940261832BD2400BD66CB /* UIUtil.m */; }; 451F8A3C1FD71392005CB9DA /* UIUtil.h in Headers */ = {isa = PBXBuildFile; fileRef = B97940251832BD2400BD66CB /* UIUtil.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -490,6 +490,7 @@ 4CC0B59C20EC5F2E00CF6EE0 /* ConversationConfigurationSyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC0B59B20EC5F2E00CF6EE0 /* ConversationConfigurationSyncOperation.swift */; }; 4CC1ECF9211A47CE00CC13BE /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4CC1ECF8211A47CD00CC13BE /* StoreKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */; }; + 4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC613352227A00400E21A3A /* ConversationSearch.swift */; }; 4CEB78C92178EBAB00F315D2 /* OWSSessionResetJobRecord.m in Sources */ = {isa = PBXBuildFile; fileRef = 4CEB78C82178EBAB00F315D2 /* OWSSessionResetJobRecord.m */; }; 4CFE6B6C21F92BA700006701 /* LegacyNotificationsAdaptee.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFE6B6B21F92BA700006701 /* LegacyNotificationsAdaptee.swift */; }; 70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70377AAA1918450100CAF501 /* MobileCoreServices.framework */; }; @@ -1055,7 +1056,7 @@ 450DF2081E0DD2C6003D14BE /* UserNotificationsAdaptee.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; name = UserNotificationsAdaptee.swift; path = UserInterface/Notifications/UserNotificationsAdaptee.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 451166BF1FD86B98000739BA /* AccountManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountManager.swift; sourceTree = ""; }; 451764291DE939FD00EDB8B9 /* ContactCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactCell.swift; sourceTree = ""; }; - 451777C71FD61554001225FF /* ConversationSearcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationSearcher.swift; sourceTree = ""; }; + 451777C71FD61554001225FF /* FullTextSearcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullTextSearcher.swift; sourceTree = ""; }; 451A13B01E13DED2000A50FD /* AppNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; name = AppNotifications.swift; path = UserInterface/Notifications/AppNotifications.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 452037CF1EE84975004E4CDF /* DebugUISessionState.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DebugUISessionState.h; sourceTree = ""; }; 452037D01EE84975004E4CDF /* DebugUISessionState.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DebugUISessionState.m; sourceTree = ""; }; @@ -1223,6 +1224,7 @@ 4CC0B59B20EC5F2E00CF6EE0 /* ConversationConfigurationSyncOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationConfigurationSyncOperation.swift; sourceTree = ""; }; 4CC1ECF8211A47CD00CC13BE /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; 4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateNag.swift; sourceTree = ""; }; + 4CC613352227A00400E21A3A /* ConversationSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSearch.swift; sourceTree = ""; }; 4CEB78C72178EBAB00F315D2 /* OWSSessionResetJobRecord.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OWSSessionResetJobRecord.h; sourceTree = ""; }; 4CEB78C82178EBAB00F315D2 /* OWSSessionResetJobRecord.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OWSSessionResetJobRecord.m; sourceTree = ""; }; 4CFB4E9B220BC56D00ECB4DE /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = translations/nb.lproj/Localizable.strings; sourceTree = ""; }; @@ -1577,7 +1579,7 @@ 4C948FF62146EB4800349F0D /* BlockListCache.swift */, 343D3D991E9283F100165CA4 /* BlockListUIUtils.h */, 343D3D9A1E9283F100165CA4 /* BlockListUIUtils.m */, - 451777C71FD61554001225FF /* ConversationSearcher.swift */, + 451777C71FD61554001225FF /* FullTextSearcher.swift */, 3466087120E550F300AFFE73 /* ConversationStyle.swift */, 34480B4D1FD0A7A300BC14EF /* DebugLogger.h */, 34480B4E1FD0A7A300BC14EF /* DebugLogger.m */, @@ -2074,6 +2076,7 @@ 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */, 34B3F8331E8DF1700035BE1A /* ViewControllers */, 76EB052B18170B33006006FC /* Views */, + 4CC613352227A00400E21A3A /* ConversationSearch.swift */, ); name = UserInterface; sourceTree = ""; @@ -3382,7 +3385,7 @@ 452C7CA72037628B003D51A5 /* Weak.swift in Sources */, 34D5872F208E2C4200D2255A /* OWS109OutgoingMessageState.m in Sources */, 34AC09F8211B39B100997B47 /* CountryCodeViewController.m in Sources */, - 451F8A341FD710C3005CB9DA /* ConversationSearcher.swift in Sources */, + 451F8A341FD710C3005CB9DA /* FullTextSearcher.swift in Sources */, 346129FE1FD5F31400532771 /* OWS106EnsureProfileComplete.swift in Sources */, 34AC0A10211B39EA00997B47 /* TappableView.swift in Sources */, 346129F91FD5F31400532771 /* OWS104CreateRecipientIdentities.m in Sources */, @@ -3541,6 +3544,7 @@ 340FC8BC204DAC8D007AEB0F /* FingerprintViewController.m in Sources */, 450DF2051E0D74AC003D14BE /* Platform.swift in Sources */, 34A4C61E221613D00042EF2E /* OnboardingVerificationViewController.swift in Sources */, + 4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */, 340FC8B2204DAC8D007AEB0F /* AdvancedSettingsTableViewController.m in Sources */, 452B999020A34B6B006F2F9E /* AddContactShareToExistingContactViewController.swift in Sources */, 346129991FD1E4DA00532771 /* SignalApp.m in Sources */, diff --git a/Signal/Images.xcassets/ic_chevron_down.imageset/Contents.json b/Signal/Images.xcassets/ic_chevron_down.imageset/Contents.json new file mode 100644 index 000000000..fd6b5101b --- /dev/null +++ b/Signal/Images.xcassets/ic_chevron_down.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "chevron-down-24@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "chevron-down-24@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "chevron-down-24@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Signal/Images.xcassets/ic_chevron_down.imageset/chevron-down-24@1x.png b/Signal/Images.xcassets/ic_chevron_down.imageset/chevron-down-24@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..b2c436a05464e527237f8ed49ae538401879ecfc GIT binary patch literal 234 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GGLLkg|>2BR0px_)& z7sn8f&bOhrc^edXbo>set$UKucZj3!5u2%ssolB+??e5!mdML}XJ`?aF|+F0g{y!0 ze?&NNC_ZCr+kC+NogbP0l+XkKlQdd! literal 0 HcmV?d00001 diff --git a/Signal/Images.xcassets/ic_chevron_down.imageset/chevron-down-24@2x.png b/Signal/Images.xcassets/ic_chevron_down.imageset/chevron-down-24@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..1762a629cbf0b58eecbb0346505505252d550e62 GIT binary patch literal 359 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?2=RS;(M3{v^Pz`!W! z>EalY(fD>|EnkxX&rxgt1HK>r)mO6gJ>pH9cBbXSF3k(uyIy+Fda!uSUthbnWpg&i zL~i#kmuIkF!0;iAp+cKsk0`@EE{6QS>JDC+T8!3nuDoVv$a=Kc<@JJzzi#+fF05?t zn9{QJhUuwqTh2*J-v7dU|A1TZ$YU*iqN98H!mG?j|eB_=Ni~3_FZ8) z%>5`s*FYhW;mAy>gp7kN7S?7GYZDwIdN|$mbrjbeV@f`;Cep7Z%}9EKo#?>?r+{_J zXHy(Dyh}+iP*|}&@$8nC7jKPMuAO;IU{?Bfk?TIm9Ivdnc$8Su1y{^u;kacq=bL(b zt?HiZ{odz(a(arDMldWE*?*=I?w43;2bZ|yE$ymOwsRAKA;;k9>gTe~DWM4fTjh;# literal 0 HcmV?d00001 diff --git a/Signal/Images.xcassets/ic_chevron_down.imageset/chevron-down-24@3x.png b/Signal/Images.xcassets/ic_chevron_down.imageset/chevron-down-24@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..b0c52639771fd85ec6134624afd68d1a8ff6d8bd GIT binary patch literal 521 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!oCO|{#S9FJ<{->y95KI&fq`+i zr;B4q#hkaZALbr15NOq(+u$3~7-$vc{*ohA*sLIRmb}ONN9-mi@4CL9taG7Q;kmyd z<0GH7royLdbiQ?13%N{CXkrmm=}>TF;Z*VvaBSirAf%NsYgusqg}^!K$4x~{_8%ua3?951c={prw|*7vnroF-H< zCtE;x2+W6wD zqf5{ZneJaaO-ldlChvP@9I@Q{XV0|cNS0JL?+=7Rky9!$ZGYO;U2}cf!tMa$mci52 K&t;ucLK6UdWXR?K literal 0 HcmV?d00001 diff --git a/Signal/Images.xcassets/ic_chevron_up.imageset/Contents.json b/Signal/Images.xcassets/ic_chevron_up.imageset/Contents.json new file mode 100644 index 000000000..216f26cee --- /dev/null +++ b/Signal/Images.xcassets/ic_chevron_up.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "chevron-up-24@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "chevron-up-24@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "chevron-up-24@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Signal/Images.xcassets/ic_chevron_up.imageset/chevron-up-24@1x.png b/Signal/Images.xcassets/ic_chevron_up.imageset/chevron-up-24@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..c5e0e86f5080e4dd8c881a6f951c5348430f0c2a GIT binary patch literal 235 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GGLLkg|>2BR0px|6j z7sn8f&bL$Vavo6NY0;m1V4}><#W#F+Id&}&sh^z1U27mPA^X$ZfW=47{4m<)|MK*M zP-Z0~mo4oku8!J&G$#o)uv^8;U0UC}YP*^mtLB~5?2AhTSaz(;+!}Xr0sE_3ZHv%A z#tXBi9GZML)69tL`Zu40q5reR^kzPpT*I*8OpV#IO_B?bvmL8tKJs0m?>~nPW7?dr h4|JOMOEc_Xu)TF{P4Sv|cA!HUJYD@<);T3K0RRmnTHgQw literal 0 HcmV?d00001 diff --git a/Signal/Images.xcassets/ic_chevron_up.imageset/chevron-up-24@2x.png b/Signal/Images.xcassets/ic_chevron_up.imageset/chevron-up-24@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a4b93b64ec3e69bf61dbed17723270463e48baa7 GIT binary patch literal 351 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?2=RS;(M3{v^Pz`!Wz z>EalY(fD>oBj+Il5tsTSj8UP^W17@M)e8x9aZTLg(fxcbu>gi*cvYkIQV{U zZ>vBZ|KXPpwr~2gYeoIcn;yI8!$25Uhc=SLKx}|F!R!ko9tpU=Cr4#-#rTqF$PapKbLh*2~7Y;kd1u+ literal 0 HcmV?d00001 diff --git a/Signal/Images.xcassets/ic_chevron_up.imageset/chevron-up-24@3x.png b/Signal/Images.xcassets/ic_chevron_up.imageset/chevron-up-24@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..167c4a238fbc81a063d23e128e9bcee9b00a3def GIT binary patch literal 516 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!oCO|{#S9FJ<{->y95KI&fq`+G zr;B4q#hkadANn3P5IFi#{0PI{7Evp%#fDz)8Rjq9+}6f4R&9)D?tHITRj(PZ7nuBC zLt&!P)Sv~o_L`nwQO%^p*`cB^slfv~v6;Jz!J1se<#S5 zUb!Fi{B~}7U$L$KddXeSc0N=ykJ|9j!z1lMuQ2aP9;F8^PDafd7J(DaC3m6D+iuct%01PkfSOO2m%~)n|DZ*N3{_yUb(spl8o^$&`bVTiJ|Hv=}GprcK~Y ze{DL`(QS_I^)$A<%0}nHJkl;$89m5pOkpc_SDUkS;+X?669m%FZk>HFuHf3OX3004 z>Aibr9K2WXZ};-0=Z`ws=>PXFJ$w9E@%FOfEZ&dHiawPGmB-)pFaJ};)wSmRoSoC& ztmbZ+FYf%_)#%%+eP8%XRccxK@q`~1sReNi`xx)f*m%$?tUd}Dtqh*7elF{r5}E)! CYtAMB literal 0 HcmV?d00001 diff --git a/Signal/src/ConversationSearch.swift b/Signal/src/ConversationSearch.swift new file mode 100644 index 000000000..c98d21237 --- /dev/null +++ b/Signal/src/ConversationSearch.swift @@ -0,0 +1,289 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc +public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate { + + @objc + func conversationSearchController(_ conversationSearchController: ConversationSearchController, + didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?) + + @objc + func conversationSearchController(_ conversationSearchController: ConversationSearchController, + didSelectMessageId: String) +} + +@objc +public class ConversationSearchController: NSObject { + + @objc + public static let kMinimumSearchTextLength: UInt = 2 + + @objc + public let uiSearchController = UISearchController(searchResultsController: nil) + + @objc + public weak var delegate: ConversationSearchControllerDelegate? + + let thread: TSThread + + let resultsBar: SearchResultsBar = SearchResultsBar(frame: .zero) + + // MARK: Initializer + + @objc + required public init(thread: TSThread) { + self.thread = thread + super.init() + + resultsBar.resultsBarDelegate = self + uiSearchController.delegate = self + uiSearchController.searchResultsUpdater = self + + uiSearchController.hidesNavigationBarDuringPresentation = false + uiSearchController.dimsBackgroundDuringPresentation = false + uiSearchController.searchBar.inputAccessoryView = resultsBar + + applyTheme() + } + + func applyTheme() { + OWSSearchBar.applyTheme(to: uiSearchController.searchBar) + } + + // MARK: Dependencies + + var dbReadConnection: YapDatabaseConnection { + return OWSPrimaryStorage.shared().dbReadConnection + } +} + +extension ConversationSearchController: UISearchControllerDelegate { + public func didPresentSearchController(_ searchController: UISearchController) { + Logger.verbose("") + delegate?.didPresentSearchController?(searchController) + } + + public func didDismissSearchController(_ searchController: UISearchController) { + Logger.verbose("") + delegate?.didDismissSearchController?(searchController) + } +} + +extension ConversationSearchController: UISearchResultsUpdating { + var dbSearcher: FullTextSearcher { + return FullTextSearcher.shared + } + + public func updateSearchResults(for searchController: UISearchController) { + Logger.verbose("searchBar.text: \( searchController.searchBar.text ?? "")") + + guard let searchText = searchController.searchBar.text?.stripped else { + self.resultsBar.updateResults(resultSet: nil) + self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil) + return + } + BenchManager.startEvent(title: "Conversation Search", eventId: searchText) + + guard searchText.count >= ConversationSearchController.kMinimumSearchTextLength else { + self.resultsBar.updateResults(resultSet: nil) + self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil) + return + } + + var resultSet: ConversationScreenSearchResultSet? + self.dbReadConnection.asyncRead({ [weak self] transaction in + guard let self = self else { + return + } + resultSet = self.dbSearcher.searchWithinConversation(thread: self.thread, searchText: searchText, transaction: transaction) + }, completionBlock: { [weak self] in + guard let self = self else { + return + } + self.resultsBar.updateResults(resultSet: resultSet) + self.delegate?.conversationSearchController(self, didUpdateSearchResults: resultSet) + }) + } +} + +extension ConversationSearchController: SearchResultsBarDelegate { + func searchResultsBar(_ searchResultsBar: SearchResultsBar, + setCurrentIndex currentIndex: Int, + resultSet: ConversationScreenSearchResultSet) { + guard let searchResult = resultSet.messages[safe: currentIndex] else { + owsFailDebug("messageId was unexpectedly nil") + return + } + + BenchEventStart(title: "Conversation Search Nav", eventId: "Conversation Search Nav: \(searchResult.messageId)") + self.delegate?.conversationSearchController(self, didSelectMessageId: searchResult.messageId) + } +} + +protocol SearchResultsBarDelegate: AnyObject { + func searchResultsBar(_ searchResultsBar: SearchResultsBar, + setCurrentIndex currentIndex: Int, + resultSet: ConversationScreenSearchResultSet) +} + +class SearchResultsBar: UIToolbar { + + weak var resultsBarDelegate: SearchResultsBarDelegate? + + var showLessRecentButton: UIBarButtonItem! + var showMoreRecentButton: UIBarButtonItem! + let labelItem: UIBarButtonItem + + var resultSet: ConversationScreenSearchResultSet? + + override init(frame: CGRect) { + + labelItem = UIBarButtonItem(title: nil, style: .plain, target: nil, action: nil) + + super.init(frame: frame) + + let leftExteriorChevronMargin: CGFloat + let leftInteriorChevronMargin: CGFloat + if CurrentAppContext().isRTL { + leftExteriorChevronMargin = 8 + leftInteriorChevronMargin = 0 + } else { + leftExteriorChevronMargin = 0 + leftInteriorChevronMargin = 8 + } + + let upChevron = #imageLiteral(resourceName: "ic_chevron_up").withRenderingMode(.alwaysTemplate) + showLessRecentButton = UIBarButtonItem(image: upChevron, style: .plain, target: self, action: #selector(didTapShowLessRecent)) + showLessRecentButton.imageInsets = UIEdgeInsets(top: 2, left: leftExteriorChevronMargin, bottom: 2, right: leftInteriorChevronMargin) + showLessRecentButton.tintColor = UIColor.ows_systemPrimaryButton + + let downChevron = #imageLiteral(resourceName: "ic_chevron_down").withRenderingMode(.alwaysTemplate) + showMoreRecentButton = UIBarButtonItem(image: downChevron, style: .plain, target: self, action: #selector(didTapShowMoreRecent)) + showMoreRecentButton.imageInsets = UIEdgeInsets(top: 2, left: leftInteriorChevronMargin, bottom: 2, right: leftExteriorChevronMargin) + showMoreRecentButton.tintColor = UIColor.ows_systemPrimaryButton + + let spacer1 = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + let spacer2 = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + + self.items = [showLessRecentButton, showMoreRecentButton, spacer1, labelItem, spacer2] + + self.isTranslucent = false + self.isOpaque = true + self.barTintColor = Theme.toolbarBackgroundColor + + self.autoresizingMask = .flexibleHeight + self.translatesAutoresizingMaskIntoConstraints = false + } + + required init?(coder aDecoder: NSCoder) { + notImplemented() + } + + @objc + public func didTapShowLessRecent() { + Logger.debug("") + guard let resultSet = resultSet else { + owsFailDebug("resultSet was unexpectedly nil") + return + } + + guard let currentIndex = currentIndex else { + owsFailDebug("currentIndex was unexpectedly nil") + return + } + + guard currentIndex + 1 < resultSet.messages.count else { + owsFailDebug("showLessRecent button should be disabled") + return + } + + let newIndex = currentIndex + 1 + self.currentIndex = newIndex + updateBarItems() + resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet) + } + + @objc + public func didTapShowMoreRecent() { + Logger.debug("") + guard let resultSet = resultSet else { + owsFailDebug("resultSet was unexpectedly nil") + return + } + + guard let currentIndex = currentIndex else { + owsFailDebug("currentIndex was unexpectedly nil") + return + } + + guard currentIndex > 0 else { + owsFailDebug("showMoreRecent button should be disabled") + return + } + + let newIndex = currentIndex - 1 + self.currentIndex = newIndex + updateBarItems() + resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet) + } + + var currentIndex: Int? + + // MARK: + + func updateResults(resultSet: ConversationScreenSearchResultSet?) { + if let resultSet = resultSet { + if resultSet.messages.count > 0 { + currentIndex = min(currentIndex ?? 0, resultSet.messages.count - 1) + } else { + currentIndex = nil + } + } else { + currentIndex = nil + } + + self.resultSet = resultSet + + updateBarItems() + if let currentIndex = currentIndex, let resultSet = resultSet { + resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: currentIndex, resultSet: resultSet) + } + } + + func updateBarItems() { + guard let resultSet = resultSet else { + labelItem.title = nil + showMoreRecentButton.isEnabled = false + showLessRecentButton.isEnabled = false + return + } + + switch resultSet.messages.count { + case 0: + labelItem.title = NSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS", comment: "keyboard toolbar label when no messages match the search string") + case 1: + labelItem.title = NSLocalizedString("CONVERSATION_SEARCH_ONE_RESULT", comment: "keyboard toolbar label when exactly 1 message matches the search string") + default: + let format = NSLocalizedString("CONVERSATION_SEARCH_RESULTS_FORMAT", + comment: "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}}") + + guard let currentIndex = currentIndex else { + owsFailDebug("currentIndex was unexpectedly nil") + return + } + labelItem.title = String(format: format, currentIndex + 1, resultSet.messages.count) + } + + if let currentIndex = currentIndex { + showMoreRecentButton.isEnabled = currentIndex > 0 + showLessRecentButton.isEnabled = currentIndex + 1 < resultSet.messages.count + } else { + showMoreRecentButton.isEnabled = false + showLessRecentButton.isEnabled = false + } + } +} diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.h b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.h index d1b3fd1ed..b9b1e7723 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.h +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.h @@ -59,6 +59,8 @@ extern const UIDataDetectorTypes kOWSAllowedDataDetectorTypes; - (void)didTapShowAddToContactUIForContactShare:(ContactShareViewModel *)contactShare NS_SWIFT_NAME(didTapShowAddToContactUI(forContactShare:)); +@property (nonatomic, readonly, nullable) NSString *lastSearchedText; + @end #pragma mark - diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m index 72ce21c1b..0f2f3ef01 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m @@ -691,6 +691,7 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes } [self.class loadForTextDisplay:self.bodyTextView text:self.displayableBodyText.displayText + searchText:self.delegate.lastSearchedText textColor:self.bodyTextColor font:self.textMessageFont shouldIgnoreEvents:shouldIgnoreEvents]; @@ -698,6 +699,7 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes + (void)loadForTextDisplay:(OWSMessageTextView *)textView text:(NSString *)text + searchText:(nullable NSString *)searchText textColor:(UIColor *)textColor font:(UIFont *)font shouldIgnoreEvents:(BOOL)shouldIgnoreEvents @@ -713,8 +715,29 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes }; textView.shouldIgnoreEvents = shouldIgnoreEvents; + NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] + initWithString:text + attributes:@{ NSFontAttributeName : font, NSForegroundColorAttributeName : textColor }]; + if (searchText.length >= ConversationSearchController.kMinimumSearchTextLength) { + NSError *error; + NSRegularExpression *regex = [[NSRegularExpression alloc] initWithPattern:searchText + options:NSRegularExpressionCaseInsensitive + error:&error]; + OWSAssertDebug(error == nil); + for (NSTextCheckingResult *match in + [regex matchesInString:text options:NSMatchingWithoutAnchoringBounds range:NSMakeRange(0, text.length)]) { + + OWSAssertDebug(match.range.length >= ConversationSearchController.kMinimumSearchTextLength); + [attributedText addAttribute:NSBackgroundColorAttributeName value:UIColor.yellowColor range:match.range]; + [attributedText addAttribute:NSForegroundColorAttributeName value:UIColor.ows_blackColor range:match.range]; + } + } + // For perf, set text last. Otherwise changing font/color is more expensive. - textView.text = text; + + // We use attributedText even when we're not highlighting searched text to esnure any lingering + // attributes are reset. + textView.attributedText = attributedText; } - (BOOL)shouldShowSenderName @@ -1259,6 +1282,7 @@ const UIDataDetectorTypes kOWSAllowedDataDetectorTypes [self.bodyTextView removeFromSuperview]; self.bodyTextView.text = nil; + self.bodyTextView.attributedText = nil; self.bodyTextView.hidden = YES; self.bubbleView.bubbleColor = nil; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationMessageMapping.swift b/Signal/src/ViewControllers/ConversationView/ConversationMessageMapping.swift index 7a94592fd..f48503948 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationMessageMapping.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationMessageMapping.swift @@ -211,7 +211,7 @@ public class ConversationMessageMapping: NSObject { // Tries to ensure that the load window includes a given item. // On success, returns the index path of that item. // On failure, returns nil. - @objc + @objc(ensureLoadWindowContainsUniqueId:transaction:) public func ensureLoadWindowContains(uniqueId: String, transaction: YapDatabaseReadTransaction) -> IndexPath? { if let oldIndex = loadedUniqueIds().firstIndex(of: uniqueId) { diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index fbfc11240..022f6af4e 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -141,6 +141,7 @@ typedef enum : NSUInteger { ConversationViewLayoutDelegate, ConversationViewCellDelegate, ConversationInputTextViewDelegate, + ConversationSearchControllerDelegate, LongTextViewDelegate, MessageActionsDelegate, MessageDetailViewDelegate, @@ -195,6 +196,8 @@ typedef enum : NSUInteger { @property (nonatomic) BOOL userHasScrolled; @property (nonatomic, nullable) NSDate *lastMessageSentDate; +@property (nonatomic, nullable) UIBarButtonItem *customBackButton; + @property (nonatomic) BOOL showLoadMoreHeader; @property (nonatomic) UILabel *loadMoreHeader; @property (nonatomic) uint64_t lastVisibleSortId; @@ -227,6 +230,10 @@ typedef enum : NSUInteger { @property (nonatomic) ScrollContinuity scrollContinuity; @property (nonatomic, nullable) NSTimer *autoLoadMoreTimer; +@property (nonatomic, readonly) ConversationSearchController *searchController; +@property (nonatomic, nullable) NSString *lastSearchedText; +@property (nonatomic) BOOL isShowingSearchUI; + @end #pragma mark - @@ -509,6 +516,9 @@ typedef enum : NSUInteger { [self updateConversationSnapshot]; + _searchController = [[ConversationSearchController alloc] initWithThread:thread]; + _searchController.delegate = self; + [self updateShouldObserveVMUpdates]; self.reloadTimer = [NSTimer weakScheduledTimerWithTimeInterval:1.f @@ -1391,6 +1401,7 @@ typedef enum : NSUInteger { - (void)createBackButton { UIBarButtonItem *backItem = [self createOWSBackButton]; + self.customBackButton = backItem; if (backItem.customView) { // This method gets called multiple times, so it's important we re-layout the unread badge // with respect to the new backItem. @@ -1425,11 +1436,23 @@ typedef enum : NSUInteger { - (void)updateBarButtonItems { + self.navigationItem.hidesBackButton = NO; + if (self.customBackButton) { + self.navigationItem.leftBarButtonItem = self.customBackButton; + } + if (self.userLeftGroup) { self.navigationItem.rightBarButtonItems = @[]; return; } + if (self.isShowingSearchUI) { + self.navigationItem.rightBarButtonItems = @[]; + self.navigationItem.leftBarButtonItem = nil; + self.navigationItem.hidesBackButton = YES; + return; + } + const CGFloat kBarButtonSize = 44; NSMutableArray *barButtons = [NSMutableArray new]; if ([self canCall]) { @@ -3944,19 +3967,107 @@ typedef enum : NSUInteger { [self updateGroupModelTo:groupModel successCompletion:nil]; } -- (void)popAllConversationSettingsViews +- (void)popAllConversationSettingsViewsWithCompletion:(void (^_Nullable)(void))completionBlock { if (self.presentedViewController) { - [self.presentedViewController - dismissViewControllerAnimated:YES - completion:^{ - [self.navigationController popToViewController:self animated:YES]; - }]; + [self.presentedViewController dismissViewControllerAnimated:YES + completion:^{ + [self.navigationController + popToViewController:self + animated:YES + completion:completionBlock]; + }]; } else { - [self.navigationController popToViewController:self animated:YES]; + [self.navigationController popToViewController:self animated:YES completion:completionBlock]; } } +#pragma mark - Conversation Search + +- (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController +{ + [self showSearchUI]; + [self popAllConversationSettingsViewsWithCompletion:^{ + // This delay is unfortunate, but without it, self.searchController.uiSearchController.searchBar + // isn't yet ready to become first responder. Presumably we're still mid transition. + // A hardcorded constant like this isn't great because it's either too slow, making our users + // wait, or too fast, and fails to wait long enough to be ready to become first responder. + // Luckily in this case the stakes aren't catastrophic. In the case that we're too aggressive + // the user will just have to manually tap into the search field before typing. + + // Leaving this assert in as proof that we're not ready to become first responder yet. + // If this assert fails, *great* maybe we can get rid of this delay. + OWSAssertDebug(![self.searchController.uiSearchController.searchBar canBecomeFirstResponder]); + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self.searchController.uiSearchController.searchBar becomeFirstResponder]; + }); + }]; +} + +- (void)showSearchUI +{ + self.isShowingSearchUI = YES; + self.navigationItem.titleView = self.searchController.uiSearchController.searchBar; + [self updateBarButtonItems]; +} + +- (void)hideSearchUI +{ + self.isShowingSearchUI = NO; + + self.navigationItem.titleView = self.headerView; + [self updateBarButtonItems]; + + // restore first responder to VC + [self becomeFirstResponder]; +} + +#pragma mark ConversationSearchControllerDelegate + +- (void)didDismissSearchController:(UISearchController *)searchController +{ + OWSLogVerbose(@""); + OWSAssertIsOnMainThread(); + [self hideSearchUI]; +} + +- (void)conversationSearchController:(ConversationSearchController *)conversationSearchController + didUpdateSearchResults:(nullable ConversationScreenSearchResultSet *)conversationScreenSearchResultSet +{ + OWSAssertIsOnMainThread(); + + OWSLogInfo(@"conversationScreenSearchResultSet: %@", conversationScreenSearchResultSet.debugDescription); + self.lastSearchedText = conversationScreenSearchResultSet.searchText; + [UIView performWithoutAnimation:^{ + [self.collectionView reloadItemsAtIndexPaths:self.collectionView.indexPathsForVisibleItems]; + }]; + if (conversationScreenSearchResultSet) { + [BenchManager completeEventWithEventId:self.lastSearchedText]; + } +} + +- (void)conversationSearchController:(ConversationSearchController *)conversationSearchController + didSelectMessageId:(NSString *)messageId +{ + OWSLogDebug(@"messageId: %@", messageId); + [self scrollToInteractionId:messageId]; + [BenchManager completeEventWithEventId:[NSString stringWithFormat:@"Conversation Search Nav: %@", messageId]]; +} + +- (void)scrollToInteractionId:(NSString *)interactionId +{ + NSIndexPath *_Nullable indexPath = [self.conversationViewModel ensureLoadWindowContainsInteractionId:interactionId]; + if (!indexPath) { + OWSFailDebug(@"unable to find indexPath"); + return; + } + + [self.collectionView scrollToItemAtIndexPath:indexPath + atScrollPosition:UICollectionViewScrollPositionCenteredVertically + animated:YES]; +} + #pragma mark - ConversationViewLayoutDelegate - (NSArray> *)layoutItems diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.h b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.h index d13a38c36..8b218d541 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.h +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.h @@ -109,6 +109,7 @@ typedef NS_ENUM(NSUInteger, ConversationUpdateItemType) { - (BOOL)canLoadMoreItems; - (nullable NSIndexPath *)ensureLoadWindowContainsQuotedReply:(OWSQuotedReplyModel *)quotedReply; +- (nullable NSIndexPath *)ensureLoadWindowContainsInteractionId:(NSString *)interactionId; - (void)appendUnsavedOutgoingTextMessage:(TSOutgoingMessage *)outgoingMessage; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m index 75428952d..99c928218 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m @@ -1595,8 +1595,32 @@ static const int kYapDatabaseRangeMaxLength = 25000; return; } - indexPath = [self.messageMapping ensureLoadWindowContainsWithUniqueId:quotedInteraction.uniqueId - transaction:transaction]; + indexPath = + [self.messageMapping ensureLoadWindowContainsUniqueId:quotedInteraction.uniqueId transaction:transaction]; + }]; + + self.collapseCutoffDate = [NSDate new]; + + [self ensureDynamicInteractionsAndUpdateIfNecessary:NO]; + + if (![self reloadViewItems]) { + OWSFailDebug(@"failed to reload view items in resetMapping."); + } + + [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; + [self.delegate conversationViewModelRangeDidChange]; + + return indexPath; +} + +- (nullable NSIndexPath *)ensureLoadWindowContainsInteractionId:(NSString *)interactionId +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(interactionId); + + __block NSIndexPath *_Nullable indexPath = nil; + [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + indexPath = [self.messageMapping ensureLoadWindowContainsUniqueId:interactionId transaction:transaction]; }]; self.collapseCutoffDate = [NSDate new]; diff --git a/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift b/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift index 95f3c10cc..fd34cb02c 100644 --- a/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift +++ b/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift @@ -37,8 +37,8 @@ class ConversationSearchViewController: UITableViewController, BlockListCacheDel return OWSPrimaryStorage.shared().uiDatabaseConnection } - var searcher: ConversationSearcher { - return ConversationSearcher.shared + var searcher: FullTextSearcher { + return FullTextSearcher.shared } private var contactsManager: OWSContactsManager { diff --git a/Signal/src/ViewControllers/MessageDetailViewController.swift b/Signal/src/ViewControllers/MessageDetailViewController.swift index 98867df98..822214764 100644 --- a/Signal/src/ViewControllers/MessageDetailViewController.swift +++ b/Signal/src/ViewControllers/MessageDetailViewController.swift @@ -763,6 +763,10 @@ class MessageDetailViewController: OWSViewController, MediaGalleryDataSourceDele UIPasteboard.general.string = messageTimestamp } + var lastSearchedText: String? { + return nil + } + // MediaGalleryDataSourceDelegate func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject) { diff --git a/Signal/src/ViewControllers/NewContactThreadViewController.m b/Signal/src/ViewControllers/NewContactThreadViewController.m index 912a52be8..ae7eea43a 100644 --- a/Signal/src/ViewControllers/NewContactThreadViewController.m +++ b/Signal/src/ViewControllers/NewContactThreadViewController.m @@ -45,7 +45,7 @@ NS_ASSUME_NONNULL_BEGIN MFMessageComposeViewControllerDelegate> @property (nonatomic, readonly) ContactsViewHelper *contactsViewHelper; -@property (nonatomic, readonly) ConversationSearcher *conversationSearcher; +@property (nonatomic, readonly) FullTextSearcher *fullTextSearcher; @property (nonatomic, readonly) UIView *noSignalContactsView; @@ -75,9 +75,9 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - Dependencies -- (ConversationSearcher *)conversationSearcher +- (FullTextSearcher *)fullTextSearcher { - return ConversationSearcher.shared; + return FullTextSearcher.shared; } - (YapDatabaseConnection *)uiDatabaseConnection @@ -903,9 +903,9 @@ NS_ASSUME_NONNULL_BEGIN [self.uiDatabaseConnection asyncReadWithBlock:^(YapDatabaseReadTransaction *_Nonnull transaction) { - self.searchResults = [self.conversationSearcher searchForComposeScreenWithSearchText:searchText - transaction:transaction - contactsManager:self.contactsManager]; + self.searchResults = [self.fullTextSearcher searchForComposeScreenWithSearchText:searchText + transaction:transaction + contactsManager:self.contactsManager]; } completionBlock:^{ __typeof(self) strongSelf = weakSelf; diff --git a/Signal/src/ViewControllers/NewGroupViewController.h b/Signal/src/ViewControllers/NewGroupViewController.h index b8a147dc5..a5dfa1b19 100644 --- a/Signal/src/ViewControllers/NewGroupViewController.h +++ b/Signal/src/ViewControllers/NewGroupViewController.h @@ -1,16 +1,13 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // -#import "OWSConversationSettingsViewDelegate.h" #import NS_ASSUME_NONNULL_BEGIN @interface NewGroupViewController : OWSViewController -@property (nonatomic, weak) id delegate; - @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ThreadSettings/OWSConversationSettingsViewController.m b/Signal/src/ViewControllers/ThreadSettings/OWSConversationSettingsViewController.m index d2ace848a..80f613f93 100644 --- a/Signal/src/ViewControllers/ThreadSettings/OWSConversationSettingsViewController.m +++ b/Signal/src/ViewControllers/ThreadSettings/OWSConversationSettingsViewController.m @@ -321,13 +321,6 @@ const CGFloat kIconViewLength = 24; mainSection.customHeaderView = [self mainSectionHeader]; mainSection.customHeaderHeight = @(100.f); - [mainSection addItem:[OWSTableItem itemWithCustomCellBlock:^{ - return [weakSelf disclosureCellWithName:MediaStrings.allMedia iconName:@"actionsheet_camera_roll_black"]; - } - actionBlock:^{ - [weakSelf showMediaGallery]; - }]]; - if ([self.thread isKindOfClass:[TSContactThread class]] && self.contactsManager.supportsContactEditing && !self.hasExistingContact) { [mainSection addItem:[OWSTableItem itemWithCustomCellBlock:^{ @@ -354,6 +347,28 @@ const CGFloat kIconViewLength = 24; }]]; } + [mainSection addItem:[OWSTableItem + itemWithCustomCellBlock:^{ + return [weakSelf disclosureCellWithName:MediaStrings.allMedia + iconName:@"actionsheet_camera_roll_black"]; + } + actionBlock:^{ + [weakSelf showMediaGallery]; + }]]; + + // TODO icon + [mainSection addItem:[OWSTableItem + itemWithCustomCellBlock:^{ + NSString *title = NSLocalizedString(@"CONVERSATION_SETTINGS_SEARCH", + @"Table cell label in conversation settings which returns the user to the " + @"conversation with 'search mode' activated"); + return + [weakSelf disclosureCellWithName:title iconName:@"actionsheet_camera_roll_black"]; + } + actionBlock:^{ + [weakSelf tappedConversationSearch]; + }]]; + if (!isNoteToSelf && !self.isGroupThread && self.thread.hasSafetyNumbers) { [mainSection addItem:[OWSTableItem itemWithCustomCellBlock:^{ return [weakSelf @@ -1331,6 +1346,11 @@ const CGFloat kIconViewLength = 24; [mediaGallery pushTileViewFromNavController:(OWSNavigationController *)self.navigationController]; } +- (void)tappedConversationSearch +{ + [self.conversationSettingsViewDelegate conversationSettingsDidRequestConversationSearch:self]; +} + #pragma mark - Notifications - (void)identityStateDidChange:(NSNotification *)notification diff --git a/Signal/src/ViewControllers/ThreadSettings/OWSConversationSettingsViewDelegate.h b/Signal/src/ViewControllers/ThreadSettings/OWSConversationSettingsViewDelegate.h index 167ef038f..06b8e515c 100644 --- a/Signal/src/ViewControllers/ThreadSettings/OWSConversationSettingsViewDelegate.h +++ b/Signal/src/ViewControllers/ThreadSettings/OWSConversationSettingsViewDelegate.h @@ -1,17 +1,19 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // NS_ASSUME_NONNULL_BEGIN +@class OWSConversationSettingsViewController; @class TSGroupModel; @protocol OWSConversationSettingsViewDelegate - (void)conversationColorWasUpdated; - (void)groupWasUpdated:(TSGroupModel *)groupModel; +- (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController; -- (void)popAllConversationSettingsViews; +- (void)popAllConversationSettingsViewsWithCompletion:(void (^_Nullable)(void))completionBlock; @end diff --git a/Signal/src/ViewControllers/ThreadSettings/UpdateGroupViewController.m b/Signal/src/ViewControllers/ThreadSettings/UpdateGroupViewController.m index 1baa19bec..2355971d7 100644 --- a/Signal/src/ViewControllers/ThreadSettings/UpdateGroupViewController.m +++ b/Signal/src/ViewControllers/ThreadSettings/UpdateGroupViewController.m @@ -431,7 +431,8 @@ NS_ASSUME_NONNULL_BEGIN [self updateGroup]; - [self.conversationSettingsViewDelegate popAllConversationSettingsViews]; + [self.conversationSettingsViewDelegate + popAllConversationSettingsViewsWithCompletion:nil]; }]]; [controller addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"ALERT_DONT_SAVE", @"The label for the 'don't save' button in action sheets.") @@ -448,7 +449,7 @@ NS_ASSUME_NONNULL_BEGIN [self updateGroup]; - [self.conversationSettingsViewDelegate popAllConversationSettingsViews]; + [self.conversationSettingsViewDelegate popAllConversationSettingsViewsWithCompletion:nil]; } - (void)groupNameDidChange:(id)sender diff --git a/Signal/test/util/SearcherTest.swift b/Signal/test/util/SearcherTest.swift index 6a2a6523a..b50dc646e 100644 --- a/Signal/test/util/SearcherTest.swift +++ b/Signal/test/util/SearcherTest.swift @@ -8,7 +8,7 @@ import XCTest // TODO: We might be able to merge this with OWSFakeContactsManager. @objc -class ConversationSearcherContactsManager: NSObject, ContactsManagerProtocol { +class FullTextSearcherContactsManager: NSObject, ContactsManagerProtocol { func displayName(forPhoneIdentifier recipientId: String?, transaction: YapDatabaseReadTransaction) -> String { return self.displayName(forPhoneIdentifier: recipientId) } @@ -57,11 +57,11 @@ class ConversationSearcherContactsManager: NSObject, ContactsManagerProtocol { private let bobRecipientId = "+49030183000" private let aliceRecipientId = "+12345678900" -class ConversationSearcherTest: SignalBaseTest { +class FullTextSearcherTest: SignalBaseTest { // MARK: - Dependencies - var searcher: ConversationSearcher { - return ConversationSearcher.shared + var searcher: FullTextSearcher { + return FullTextSearcher.shared } var dbConnection: YapDatabaseConnection { @@ -80,7 +80,7 @@ class ConversationSearcherTest: SignalBaseTest { FullTextSearchFinder.ensureDatabaseExtensionRegistered(storage: OWSPrimaryStorage.shared()) // Replace this singleton. - SSKEnvironment.shared.contactsManager = ConversationSearcherContactsManager() + SSKEnvironment.shared.contactsManager = FullTextSearcherContactsManager() self.dbConnection.readWrite { transaction in let bookModel = TSGroupModel(title: "Book Club", memberIds: [aliceRecipientId, bobRecipientId], image: nil, groupId: Randomness.generateRandomBytes(kGroupIdLength)) diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 799bd1eba..c884c1abc 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -569,6 +569,15 @@ /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Delete Conversation?"; +/* keyboard toolbar label when no messages match the search string */ +"CONVERSATION_SEARCH_NO_RESULTS" = "No matches"; + +/* keyboard toolbar label when exactly 1 message matches the search string */ +"CONVERSATION_SEARCH_ONE_RESULT" = "1 match"; + +/* 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}} */ +"CONVERSATION_SEARCH_RESULTS_FORMAT" = "%d of %d matches"; + /* title for conversation settings screen */ "CONVERSATION_SETTINGS" = "Conversation Settings"; @@ -620,6 +629,9 @@ /* Label for 'new contact' button in conversation settings view. */ "CONVERSATION_SETTINGS_NEW_CONTACT" = "Create New Contact"; +/* Table cell label in conversation settings which returns the user to the conversation with 'search mode' activated */ +"CONVERSATION_SETTINGS_SEARCH" = "Search Conversation"; + /* Label for button that opens conversation settings. */ "CONVERSATION_SETTINGS_TAP_TO_CHANGE" = "Tap to Change"; diff --git a/SignalMessaging/ViewControllers/SelectThreadViewController.m b/SignalMessaging/ViewControllers/SelectThreadViewController.m index cbf67ae66..41d8da3e0 100644 --- a/SignalMessaging/ViewControllers/SelectThreadViewController.m +++ b/SignalMessaging/ViewControllers/SelectThreadViewController.m @@ -33,7 +33,7 @@ NS_ASSUME_NONNULL_BEGIN NewNonContactConversationViewControllerDelegate> @property (nonatomic, readonly) ContactsViewHelper *contactsViewHelper; -@property (nonatomic, readonly) ConversationSearcher *conversationSearcher; +@property (nonatomic, readonly) FullTextSearcher *fullTextSearcher; @property (nonatomic, readonly) ThreadViewHelper *threadViewHelper; @property (nonatomic, readonly) YapDatabaseConnection *uiDatabaseConnection; @@ -59,7 +59,7 @@ NS_ASSUME_NONNULL_BEGIN self.view.backgroundColor = Theme.backgroundColor; _contactsViewHelper = [[ContactsViewHelper alloc] initWithDelegate:self]; - _conversationSearcher = ConversationSearcher.shared; + _fullTextSearcher = FullTextSearcher.shared; _threadViewHelper = [ThreadViewHelper new]; _threadViewHelper.delegate = self; @@ -341,7 +341,7 @@ NS_ASSUME_NONNULL_BEGIN { NSString *searchTerm = [[self.searchBar text] ows_stripped]; - return [self.conversationSearcher filterThreads:self.threadViewHelper.threads withSearchText:searchTerm]; + return [self.fullTextSearcher filterThreads:self.threadViewHelper.threads withSearchText:searchTerm]; } - (NSArray *)filteredSignalAccountsWithSearchText diff --git a/SignalMessaging/Views/ContactsViewHelper.m b/SignalMessaging/Views/ContactsViewHelper.m index 229eb690e..4373047bf 100644 --- a/SignalMessaging/Views/ContactsViewHelper.m +++ b/SignalMessaging/Views/ContactsViewHelper.m @@ -32,7 +32,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) BOOL shouldNotifyDelegateOfUpdatedContacts; @property (nonatomic) BOOL hasUpdatedContactsAtLeastOnce; @property (nonatomic) OWSProfileManager *profileManager; -@property (nonatomic, readonly) ConversationSearcher *conversationSearcher; +@property (nonatomic, readonly) FullTextSearcher *fullTextSearcher; @end @@ -54,7 +54,7 @@ NS_ASSUME_NONNULL_BEGIN _blockListCache = [OWSBlockListCache new]; [_blockListCache startObservingAndSyncStateWithDelegate:self]; - _conversationSearcher = ConversationSearcher.shared; + _fullTextSearcher = FullTextSearcher.shared; _contactsManager = Environment.shared.contactsManager; _profileManager = [OWSProfileManager sharedManager]; @@ -210,8 +210,7 @@ NS_ASSUME_NONNULL_BEGIN NSMutableArray *signalAccountsToSearch = [self.signalAccounts mutableCopy]; SignalAccount *selfAccount = [[SignalAccount alloc] initWithRecipientId:self.localNumber]; [signalAccountsToSearch addObject:selfAccount]; - return [self.conversationSearcher filterSignalAccounts:signalAccountsToSearch - withSearchText:searchText]; + return [self.fullTextSearcher filterSignalAccounts:signalAccountsToSearch withSearchText:searchText]; } - (BOOL)doesContact:(Contact *)contact matchSearchTerm:(NSString *)searchTerm diff --git a/SignalMessaging/Views/OWSSearchBar.h b/SignalMessaging/Views/OWSSearchBar.h index 4e6586f96..bbac537f4 100644 --- a/SignalMessaging/Views/OWSSearchBar.h +++ b/SignalMessaging/Views/OWSSearchBar.h @@ -1,11 +1,13 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // NS_ASSUME_NONNULL_BEGIN @interface OWSSearchBar : UISearchBar ++ (void)applyThemeToSearchBar:(UISearchBar *)searchBar; + @end NS_ASSUME_NONNULL_END diff --git a/SignalMessaging/Views/OWSSearchBar.m b/SignalMessaging/Views/OWSSearchBar.m index e3813290a..2d00089af 100644 --- a/SignalMessaging/Views/OWSSearchBar.m +++ b/SignalMessaging/Views/OWSSearchBar.m @@ -1,5 +1,5 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // #import "OWSSearchBar.h" @@ -54,36 +54,41 @@ NS_ASSUME_NONNULL_BEGIN } - (void)ows_applyTheme +{ + [self.class applyThemeToSearchBar:self]; +} + ++ (void)applyThemeToSearchBar:(UISearchBar *)searchBar { OWSAssertIsOnMainThread(); UIColor *foregroundColor = Theme.placeholderColor; - self.barTintColor = Theme.backgroundColor; - self.barStyle = Theme.barStyle; + searchBar.barTintColor = Theme.backgroundColor; + searchBar.barStyle = Theme.barStyle; // Hide searchBar border. // Alternatively we could hide the border by using `UISearchBarStyleMinimal`, but that causes an issue when toggling // from light -> dark -> light theme wherein the textField background color appears darker than it should // (regardless of our re-setting textfield.backgroundColor below). - self.backgroundImage = [UIImage new]; + searchBar.backgroundImage = [UIImage new]; if (Theme.isDarkThemeEnabled) { UIImage *clearImage = [UIImage imageNamed:@"searchbar_clear"]; - [self setImage:[clearImage asTintedImageWithColor:foregroundColor] + [searchBar setImage:[clearImage asTintedImageWithColor:foregroundColor] forSearchBarIcon:UISearchBarIconClear state:UIControlStateNormal]; UIImage *searchImage = [UIImage imageNamed:@"searchbar_search"]; - [self setImage:[searchImage asTintedImageWithColor:foregroundColor] + [searchBar setImage:[searchImage asTintedImageWithColor:foregroundColor] forSearchBarIcon:UISearchBarIconSearch state:UIControlStateNormal]; } else { - [self setImage:nil forSearchBarIcon:UISearchBarIconClear state:UIControlStateNormal]; + [searchBar setImage:nil forSearchBarIcon:UISearchBarIconClear state:UIControlStateNormal]; - [self setImage:nil forSearchBarIcon:UISearchBarIconSearch state:UIControlStateNormal]; + [searchBar setImage:nil forSearchBarIcon:UISearchBarIconSearch state:UIControlStateNormal]; } - [self traverseViewHierarchyWithVisitor:^(UIView *view) { + [searchBar traverseViewHierarchyWithVisitor:^(UIView *view) { if ([view isKindOfClass:[UITextField class]]) { UITextField *textField = (UITextField *)view; textField.backgroundColor = Theme.searchFieldBackgroundColor; diff --git a/SignalMessaging/categories/UIView+OWS.swift b/SignalMessaging/categories/UIView+OWS.swift index b29b58f16..789f6b056 100644 --- a/SignalMessaging/categories/UIView+OWS.swift +++ b/SignalMessaging/categories/UIView+OWS.swift @@ -16,7 +16,7 @@ public extension UIEdgeInsets { @objc public extension UINavigationController { @objc - public func pushViewController(viewController: UIViewController, + public func pushViewController(_ viewController: UIViewController, animated: Bool, completion: (() -> Void)?) { CATransaction.begin() @@ -35,7 +35,7 @@ public extension UINavigationController { } @objc - public func popToViewController(viewController: UIViewController, + public func popToViewController(_ viewController: UIViewController, animated: Bool, completion: (() -> Void)?) { CATransaction.begin() diff --git a/SignalMessaging/utils/Bench.swift b/SignalMessaging/utils/Bench.swift index 34afa6385..298ab6b01 100644 --- a/SignalMessaging/utils/Bench.swift +++ b/SignalMessaging/utils/Bench.swift @@ -17,10 +17,10 @@ import Foundation /// } /// } public func BenchAsync(title: String, block: (@escaping () -> Void) -> Void) { - let startTime = CFAbsoluteTimeGetCurrent() + let startTime = CACurrentMediaTime() block { - let timeElapsed = CFAbsoluteTimeGetCurrent() - startTime + let timeElapsed = CACurrentMediaTime() - startTime let formattedTime = String(format: "%0.2fms", timeElapsed * 1000) Logger.debug("[Bench] title: \(title), duration: \(formattedTime)") } @@ -52,19 +52,20 @@ public func Bench(title: String, block: () throws -> Void) throws { /// crosses multiple classes, you can use the BenchEvent tools /// /// // in one class -/// let message = getMessage() /// BenchEventStart(title: "message sending", eventId: message.id) +/// beginTheWork() /// -/// ... +/// ... /// /// // in another class -/// BenchEventComplete(title: "message sending", eventId: message.id) +/// doTheLastThing() +/// BenchEventComplete(eventId: message.id) /// /// Or in objc /// /// [BenchManager startEventWithTitle:"message sending" eventId:message.id] /// ... -/// [BenchManager startEventWithTitle:"message sending" eventId:message.id] +/// [BenchManager completeEventWithEventId:eventId:message.id] public func BenchEventStart(title: String, eventId: BenchmarkEventId) { BenchAsync(title: title) { finish in runningEvents[eventId] = Event(title: title, eventId: eventId, completion: finish) diff --git a/SignalMessaging/utils/ConversationSearcher.swift b/SignalMessaging/utils/FullTextSearcher.swift similarity index 84% rename from SignalMessaging/utils/ConversationSearcher.swift rename to SignalMessaging/utils/FullTextSearcher.swift index 2cc7e1062..0b3146a20 100644 --- a/SignalMessaging/utils/ConversationSearcher.swift +++ b/SignalMessaging/utils/FullTextSearcher.swift @@ -162,7 +162,57 @@ public class ComposeScreenSearchResultSet: NSObject { } @objc -public class ConversationSearcher: NSObject { +public class MessageSearchResult: NSObject, Comparable { + + public let messageId: String + public let sortId: UInt64 + + init(messageId: String, sortId: UInt64) { + self.messageId = messageId + self.sortId = sortId + } + + // MARK: - Comparable + + public static func < (lhs: MessageSearchResult, rhs: MessageSearchResult) -> Bool { + return lhs.sortId < rhs.sortId + } +} + +@objc +public class ConversationScreenSearchResultSet: NSObject { + + @objc + public let searchText: String + + @objc + public let messages: [MessageSearchResult] + + @objc + public lazy var messageSortIds: [UInt64] = { + return messages.map { $0.sortId } + }() + + // MARK: Static members + + public static let empty: ConversationScreenSearchResultSet = ConversationScreenSearchResultSet(searchText: "", messages: []) + + // MARK: Init + + public init(searchText: String, messages: [MessageSearchResult]) { + self.searchText = searchText + self.messages = messages + } + + // MARK: - CustomDebugStringConvertible + + override public var debugDescription: String { + return "ConversationScreenSearchResultSet(searchText: \(searchText), messages: [\(messages.count) matches])" + } +} + +@objc +public class FullTextSearcher: NSObject { // MARK: - Dependencies @@ -175,7 +225,7 @@ public class ConversationSearcher: NSObject { private let finder: FullTextSearchFinder @objc - public static let shared: ConversationSearcher = ConversationSearcher() + public static let shared: FullTextSearcher = FullTextSearcher() override private init() { finder = FullTextSearchFinder() super.init() @@ -279,6 +329,39 @@ public class ConversationSearcher: NSObject { return HomeScreenSearchResultSet(searchText: searchText, conversations: conversations, contacts: otherContacts, messages: messages) } + public func searchWithinConversation(thread: TSThread, + searchText: String, + transaction: YapDatabaseReadTransaction) -> ConversationScreenSearchResultSet { + + var messages: [MessageSearchResult] = [] + + guard let threadId = thread.uniqueId else { + owsFailDebug("threadId was unexpectedly nil") + return ConversationScreenSearchResultSet.empty + } + + self.finder.enumerateObjects(searchText: searchText, transaction: transaction) { (match: Any, snippet: String?) in + if let message = match as? TSMessage { + guard message.uniqueThreadId == threadId else { + return + } + + guard let messageId = message.uniqueId else { + owsFailDebug("messageId was unexpectedly nil") + return + } + + let searchResult = MessageSearchResult(messageId: messageId, sortId: message.sortId) + messages.append(searchResult) + } + } + + // We want most recent first + messages.sort(by: >) + + return ConversationScreenSearchResultSet(searchText: searchText, messages: messages) + } + @objc(filterThreads:withSearchText:) public func filterThreads(_ threads: [TSThread], searchText: String) -> [TSThread] { guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else {