Cleaned up the rest of the search functionality

Removed some debug text which appearing in the in-conversation search UI
Fixed a number of small UI glitches
pull/612/head
Morgan Pretty 3 years ago
parent 62c886e764
commit 45d0faee6a

@ -16,7 +16,6 @@
340FC8AE204DAC8D007AEB0F /* OWSSoundSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC883204DAC8C007AEB0F /* OWSSoundSettingsViewController.m */; };
340FC8B6204DAC8D007AEB0F /* OWSQRCodeScanningViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC896204DAC8C007AEB0F /* OWSQRCodeScanningViewController.m */; };
340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC89A204DAC8D007AEB0F /* OWSConversationSettingsViewController.m */; };
341341EF2187467A00192D59 /* ConversationViewModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 341341EE2187467900192D59 /* ConversationViewModel.m */; };
3427C64320F500E000EEC730 /* OWSMessageTimerView.m in Sources */ = {isa = PBXBuildFile; fileRef = 3427C64220F500DF00EEC730 /* OWSMessageTimerView.m */; };
3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3430FE171F7751D4000EC51B /* GiphyAPI.swift */; };
34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34330AA21E79686200DF2FB9 /* OWSProgressView.m */; };
@ -32,7 +31,6 @@
3496956021A2FC8100DCFE74 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3496955F21A2FC8100DCFE74 /* CloudKit.framework */; };
34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */; };
34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */; };
34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */; };
34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B0796B1FCF46B000E248C2 /* MainAppContext.m */; };
34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */; };
34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */; };
@ -44,7 +42,6 @@
34CF078A203E6B78005C4D61 /* end_call_tone_cept.caf in Resources */ = {isa = PBXBuildFile; fileRef = 34CF0786203E6B78005C4D61 /* end_call_tone_cept.caf */; };
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F04F1F7D45A60066283D /* GifPickerCell.swift */; };
34D1F0521F7E8EA30066283D /* GiphyDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0511F7E8EA30066283D /* GiphyDownloader.swift */; };
34D1F0871F8678AA0066283D /* ConversationViewItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0701F8678AA0066283D /* ConversationViewItem.m */; };
34D2CCDA2062E7D000CB1A14 /* OWSScreenLockUI.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D2CCD92062E7D000CB1A14 /* OWSScreenLockUI.m */; };
34D5CCA91EAE3D30005515DB /* AvatarViewHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */; };
34D99CE4217509C2000AFB39 /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D99CE3217509C1000AFB39 /* AppEnvironment.swift */; };
@ -122,7 +119,6 @@
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, ); }; };
@ -434,7 +430,6 @@
C38EF2B3255B6D9C007E1867 /* UIViewController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */; };
C38EF2B4255B6D9C007E1867 /* UIView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B2255B6D9C007E1867 /* UIView+Utilities.swift */; };
C38EF30C255B6DBF007E1867 /* OWSScreenLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2E2255B6DB9007E1867 /* OWSScreenLock.swift */; };
C38EF30E255B6DBF007E1867 /* FullTextSearcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2E4255B6DB9007E1867 /* FullTextSearcher.swift */; };
C38EF317255B6DBF007E1867 /* DisplayableText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2ED255B6DBB007E1867 /* DisplayableText.swift */; };
C38EF31A255B6DBF007E1867 /* OWSAnyTouchGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F0255B6DBB007E1867 /* OWSAnyTouchGestureRecognizer.m */; };
C38EF31C255B6DBF007E1867 /* Searcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F2255B6DBC007E1867 /* Searcher.swift */; };
@ -910,8 +905,6 @@
340FC899204DAC8D007AEB0F /* OWSConversationSettingsViewDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSConversationSettingsViewDelegate.h; sourceTree = "<group>"; };
340FC89A204DAC8D007AEB0F /* OWSConversationSettingsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSConversationSettingsViewController.m; sourceTree = "<group>"; };
340FC8A0204DAC8D007AEB0F /* OWSConversationSettingsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSConversationSettingsViewController.h; sourceTree = "<group>"; };
341341ED2187467900192D59 /* ConversationViewModel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationViewModel.h; sourceTree = "<group>"; };
341341EE2187467900192D59 /* ConversationViewModel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewModel.m; sourceTree = "<group>"; };
3427C64120F500DE00EEC730 /* OWSMessageTimerView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMessageTimerView.h; sourceTree = "<group>"; };
3427C64220F500DF00EEC730 /* OWSMessageTimerView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSMessageTimerView.m; sourceTree = "<group>"; };
3430FE171F7751D4000EC51B /* GiphyAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GiphyAPI.swift; sourceTree = "<group>"; };
@ -929,7 +922,6 @@
3496955F21A2FC8100DCFE74 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; };
34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSImagePickerController.swift; sourceTree = "<group>"; };
34A8B3502190A40E00218A25 /* MediaAlbumView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaAlbumView.swift; sourceTree = "<group>"; };
34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationMessageMapping.swift; sourceTree = "<group>"; };
34B0796B1FCF46B000E248C2 /* MainAppContext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MainAppContext.m; sourceTree = "<group>"; };
34B0796C1FCF46B000E248C2 /* MainAppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MainAppContext.h; sourceTree = "<group>"; };
34B0796E1FD07B1E00E248C2 /* SignalShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SignalShareExtension.entitlements; sourceTree = "<group>"; };
@ -943,8 +935,6 @@
34CF0786203E6B78005C4D61 /* end_call_tone_cept.caf */ = {isa = PBXFileReference; lastKnownFileType = file; name = end_call_tone_cept.caf; path = Session/Meta/AudioFiles/end_call_tone_cept.caf; sourceTree = SOURCE_ROOT; };
34D1F04F1F7D45A60066283D /* GifPickerCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerCell.swift; sourceTree = "<group>"; };
34D1F0511F7E8EA30066283D /* GiphyDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GiphyDownloader.swift; sourceTree = "<group>"; };
34D1F06F1F8678AA0066283D /* ConversationViewItem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationViewItem.h; sourceTree = "<group>"; };
34D1F0701F8678AA0066283D /* ConversationViewItem.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewItem.m; sourceTree = "<group>"; };
34D2CCD82062E7D000CB1A14 /* OWSScreenLockUI.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSScreenLockUI.h; sourceTree = "<group>"; };
34D2CCD92062E7D000CB1A14 /* OWSScreenLockUI.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSScreenLockUI.m; sourceTree = "<group>"; };
34D5CCA71EAE3D30005515DB /* AvatarViewHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AvatarViewHelper.h; sourceTree = "<group>"; };
@ -1048,7 +1038,6 @@
7B7CB18A270591630079FF93 /* ShareLogsModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLogsModal.swift; sourceTree = "<group>"; };
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 = "<group>"; };
7BA7F4BA279F9F5800B3A466 /* EmptySearchResultCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptySearchResultCell.swift; sourceTree = "<group>"; };
7BA7F4BC27A216B600B3A466 /* Storage+RecentSearchResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+RecentSearchResults.swift"; sourceTree = "<group>"; };
7BA9057D27911C5800998B3C /* GlobalSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchViewController.swift; sourceTree = "<group>"; };
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 = "<group>"; };
@ -1389,7 +1378,6 @@
C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIViewController+Utilities.swift"; path = "SignalUtilitiesKit/Utilities/UIViewController+Utilities.swift"; sourceTree = SOURCE_ROOT; };
C38EF2B2255B6D9C007E1867 /* UIView+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIView+Utilities.swift"; path = "SignalUtilitiesKit/Utilities/UIView+Utilities.swift"; sourceTree = SOURCE_ROOT; };
C38EF2E2255B6DB9007E1867 /* OWSScreenLock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSScreenLock.swift; path = "SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift"; sourceTree = SOURCE_ROOT; };
C38EF2E4255B6DB9007E1867 /* FullTextSearcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FullTextSearcher.swift; path = SignalUtilitiesKit/Messaging/FullTextSearcher.swift; sourceTree = SOURCE_ROOT; };
C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProximityMonitoringManager.swift; path = SessionMessagingKit/Utilities/ProximityMonitoringManager.swift; sourceTree = SOURCE_ROOT; };
C38EF2ED255B6DBB007E1867 /* DisplayableText.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DisplayableText.swift; path = SignalUtilitiesKit/Utilities/DisplayableText.swift; sourceTree = SOURCE_ROOT; };
C38EF2EF255B6DBB007E1867 /* Weak.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Weak.swift; path = SessionUtilitiesKit/General/Weak.swift; sourceTree = SOURCE_ROOT; };
@ -1950,7 +1938,6 @@
children = (
7BA9057D27911C5800998B3C /* GlobalSearchViewController.swift */,
7BA7F4BA279F9F5800B3A466 /* EmptySearchResultCell.swift */,
7BA7F4BC27A216B600B3A466 /* Storage+RecentSearchResults.swift */,
);
path = GlobalSearch;
sourceTree = "<group>";
@ -2104,11 +2091,6 @@
B835246D25C38ABF0089A44F /* ConversationVC.swift */,
B8569AC225CB5D2900DBA3DB /* ConversationVC+Interaction.swift */,
4CC613352227A00400E21A3A /* ConversationSearch.swift */,
34D1F06F1F8678AA0066283D /* ConversationViewItem.h */,
34D1F0701F8678AA0066283D /* ConversationViewItem.m */,
341341ED2187467900192D59 /* ConversationViewModel.h */,
341341EE2187467900192D59 /* ConversationViewModel.m */,
34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */,
);
path = Conversations;
sourceTree = "<group>";
@ -2853,7 +2835,6 @@
isa = PBXGroup;
children = (
FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */,
C38EF2E4255B6DB9007E1867 /* FullTextSearcher.swift */,
);
path = Messaging;
sourceTree = "<group>";
@ -4396,7 +4377,6 @@
C38EF365255B6DCC007E1867 /* OWSTableViewController.m in Sources */,
C38EF36B255B6DCC007E1867 /* ScreenLockViewController.m in Sources */,
C38EF40C255B6DF7007E1867 /* GradientView.swift in Sources */,
C38EF30E255B6DBF007E1867 /* FullTextSearcher.swift in Sources */,
C38EF3FA255B6DF7007E1867 /* DirectionalPanGestureRecognizer.swift in Sources */,
C38EF3BB255B6DE7007E1867 /* ImageEditorStrokeItem.swift in Sources */,
C38EF3C0255B6DE7007E1867 /* ImageEditorCropViewController.swift in Sources */,
@ -4711,7 +4691,6 @@
FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */,
B879D449247E1BE300DB3608 /* PathVC.swift in Sources */,
454A84042059C787008B8C75 /* MediaTileViewController.swift in Sources */,
34D1F0871F8678AA0066283D /* ConversationViewItem.m in Sources */,
451A13B11E13DED2000A50FD /* AppNotifications.swift in Sources */,
34D99CE4217509C2000AFB39 /* AppEnvironment.swift in Sources */,
C328255225CA64470062D0A7 /* ContextMenuVC+ActionView.swift in Sources */,
@ -4778,7 +4757,6 @@
4542DF54208D40AC007B4E76 /* LoadingViewController.swift in Sources */,
34D5CCA91EAE3D30005515DB /* AvatarViewHelper.m in Sources */,
B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */,
341341EF2187467A00192D59 /* ConversationViewModel.m in Sources */,
4C21D5D8223AC60F00EF8A77 /* PhotoCapture.swift in Sources */,
C331FFF32558FF0300070591 /* PathStatusView.swift in Sources */,
4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */,
@ -4792,7 +4770,6 @@
B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */,
C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */,
B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */,
34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.swift in Sources */,
B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */,
45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */,
B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */,
@ -4856,7 +4833,6 @@
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 */,
);

@ -1,333 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc
public class ConversationMessageMapping: NSObject {
private let viewName: String
private let group: String?
// The desired number of the items to load BEFORE the pivot (see below).
@objc
public var desiredLength: UInt
typealias ItemId = String
// The list of currently loaded items.
private var itemIds = [ItemId]()
// When we enter a conversation, we want to load up to N interactions. This
// is the "initial load window".
//
// We subsequently expand the load window in two directions using two very
// different behaviors.
//
// * We expand the load window "upwards" (backwards in time) only when
// loadMore() is called, in "pages".
// * We auto-expand the load window "downwards" (forward in time) to include
// any new interactions created after the initial load.
//
// We define the "pivot" as the last item in the initial load window. This
// value is only set once.
//
// For example, if you enter a conversation with messages, 1..15:
//
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
//
// We initially load just the last 5 (if 5 is the initial desired length):
//
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// | pivot ^ | <-- load window
// pivot: 15, desired length=5.
//
// If a few more messages (16..18) are sent or received, we'll always load
// them immediately (they're after the pivot):
//
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// | pivot ^ | <-- load window
// pivot: 15, desired length=5.
//
// To load an additional page of items (perhaps due to user scrolling
// upward), we extend the desired length and thereby load more items
// before the pivot.
//
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// | pivot ^ | <-- load window
// pivot: 15, desired length=10.
//
// To reiterate:
//
// * The pivot doesn't move.
// * The desired length applies _before_ the pivot.
// * Everything after the pivot is auto-loaded.
//
// One last optimization:
//
// After an update, we _can sometimes_ move the pivot (for perf
// reasons), but we also adjust the "desired length" so that this
// no effect on the load behavior.
//
// And note: we use the pivot's sort id, not its uniqueId, which works
// even if the pivot itself is deleted.
private var pivotSortId: UInt64?
@objc
public var canLoadMore = false
@objc
public required init(group: String?, desiredLength: UInt) {
self.viewName = TSMessageDatabaseViewExtensionName
self.group = group
self.desiredLength = desiredLength
}
@objc
public func loadedUniqueIds() -> [String] {
return itemIds
}
@objc
public func contains(uniqueId: String) -> Bool {
return loadedUniqueIds().contains(uniqueId)
}
// This method can be used to extend the desired length
// and update.
@objc
public func update(withDesiredLength desiredLength: UInt, transaction: YapDatabaseReadTransaction) {
assert(desiredLength >= self.desiredLength)
self.desiredLength = desiredLength
update(transaction: transaction)
}
// This is the core method of the class. It updates the state to
// reflect the latest database state & the current desired length.
@objc
public func update(transaction: YapDatabaseReadTransaction) {
AssertIsOnMainThread()
guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else {
owsFailDebug("Could not load view.")
return
}
guard let group = group else {
owsFailDebug("No group.")
return
}
// Deserializing interactions is expensive, so we only
// do that when necessary.
let sortIdForItemId: (String) -> UInt64? = { (itemId) in
guard let interaction = TSInteraction.fetch(uniqueId: itemId, transaction: transaction) else {
owsFailDebug("Could not load interaction.")
return nil
}
return interaction.sortId
}
// If we have a "pivot", load all items AFTER the pivot and up to minDesiredLength items BEFORE the pivot.
// If we do not have a "pivot", load up to minDesiredLength BEFORE the pivot.
var newItemIds = [ItemId]()
var canLoadMore = false
let desiredLength = self.desiredLength
// Not all items "count" towards the desired length. On an initial load, all items count. Subsequently,
// only items above the pivot count.
var afterPivotCount: UInt = 0
var beforePivotCount: UInt = 0
// (void (^)(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop))block;
view.enumerateKeys(inGroup: group, with: NSEnumerationOptions.reverse) { (_, key, _, stop) in
let itemId = key
// Load "uncounted" items after the pivot if possible.
//
// As an optimization, we can skip this check (which requires
// deserializing the interaction) if beforePivotCount is non-zero,
// e.g. after we "pass" the pivot.
if beforePivotCount == 0,
let pivotSortId = self.pivotSortId {
if let sortId = sortIdForItemId(itemId) {
let isAfterPivot = sortId > pivotSortId
if isAfterPivot {
newItemIds.append(itemId)
afterPivotCount += 1
return
}
} else {
owsFailDebug("Could not determine sort id for interaction: \(itemId)")
}
}
// Load "counted" items unless the load window overflows.
if beforePivotCount >= desiredLength {
// Overflow
canLoadMore = true
stop.pointee = true
} else {
newItemIds.append(itemId)
beforePivotCount += 1
}
}
// The items need to be reversed, since we load them in reverse order.
self.itemIds = Array(newItemIds.reversed())
self.canLoadMore = canLoadMore
// Establish the pivot, if necessary and possible.
//
// Deserializing interactions is expensive. We only need to deserialize
// interactions that are "after" the pivot. So there would be performance
// benefits to moving the pivot after each update to the last loaded item.
//
// However, this would undesirable side effects. The desired length for
// conversations with very short disappearing message durations would
// continuously grow as messages appeared and disappeared.
//
// Therefore, we only move the pivot when we've accumulated N items after
// the pivot. This puts an upper bound on the number of interactions we
// have to deserialize while minimizing "load window size creep".
let kMaxItemCountAfterPivot = 32
let shouldSetPivot = (self.pivotSortId == nil ||
afterPivotCount > kMaxItemCountAfterPivot)
if shouldSetPivot {
if let newLastItemId = newItemIds.first {
// newItemIds is in reverse order, so its "first" element is actually last.
if let sortId = sortIdForItemId(newLastItemId) {
// Update the pivot.
if self.pivotSortId != nil {
self.desiredLength += afterPivotCount
}
self.pivotSortId = sortId
} else {
owsFailDebug("Could not determine sort id for interaction: \(newLastItemId)")
}
}
}
}
// 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(ensureLoadWindowContainsUniqueId:transaction:)
public func ensureLoadWindowContains(uniqueId: String,
transaction: YapDatabaseReadTransaction) -> IndexPath? {
if let oldIndex = loadedUniqueIds().firstIndex(of: uniqueId) {
return IndexPath(row: oldIndex, section: 0)
}
guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else {
owsFailDebug("Could not load view.")
return nil
}
guard let group = group else {
owsFailDebug("No group.")
return nil
}
let indexPtr: UnsafeMutablePointer<UInt> = UnsafeMutablePointer<UInt>.allocate(capacity: 1)
let wasFound = view.getGroup(nil, index: indexPtr, forKey: uniqueId, inCollection: TSInteraction.collection())
guard wasFound else {
SNLog("Could not find interaction.")
return nil
}
let index = indexPtr.pointee
let threadInteractionCount = view.numberOfItems(inGroup: group)
guard index < threadInteractionCount else {
owsFailDebug("Invalid index.")
return nil
}
// This math doesn't take into account the number of items loaded _after_ the pivot.
// That's fine; it's okay to load too many interactions here.
let desiredWindowSize: UInt = threadInteractionCount - index
self.update(withDesiredLength: desiredWindowSize, transaction: transaction)
guard let newIndex = loadedUniqueIds().firstIndex(of: uniqueId) else {
owsFailDebug("Couldn't find interaction.")
return nil
}
return IndexPath(row: newIndex, section: 0)
}
@objc
public class ConversationMessageMappingDiff: NSObject {
@objc
public let addedItemIds: Set<String>
@objc
public let removedItemIds: Set<String>
@objc
public let updatedItemIds: Set<String>
init(addedItemIds: Set<String>, removedItemIds: Set<String>, updatedItemIds: Set<String>) {
self.addedItemIds = addedItemIds
self.removedItemIds = removedItemIds
self.updatedItemIds = updatedItemIds
}
}
// Updates and then calculates which items were inserted, removed or modified.
@objc
public func updateAndCalculateDiff(transaction: YapDatabaseReadTransaction,
notifications: [NSNotification]) -> ConversationMessageMappingDiff? {
let oldItemIds = Set(self.itemIds)
self.update(transaction: transaction)
let newItemIds = Set(self.itemIds)
let removedItemIds = oldItemIds.subtracting(newItemIds)
let addedItemIds = newItemIds.subtracting(oldItemIds)
// We only notify for updated items that a) were previously loaded b) weren't also inserted or removed.
let updatedItemIds = (self.updatedItemIds(for: notifications)
.subtracting(addedItemIds)
.subtracting(removedItemIds)
.intersection(oldItemIds))
return ConversationMessageMappingDiff(addedItemIds: addedItemIds,
removedItemIds: removedItemIds,
updatedItemIds: updatedItemIds)
}
// For performance reasons, the database modification notifications are used
// to determine which items were modified. If YapDatabase ever changes the
// structure or semantics of these notifications, we'll need to update this
// code to reflect that.
private func updatedItemIds(for notifications: [NSNotification]) -> Set<String> {
var updatedItemIds = Set<String>()
for notification in notifications {
// Unpack the YDB notification, looking for row changes.
guard let userInfo =
notification.userInfo else {
owsFailDebug("Missing userInfo.")
continue
}
guard let viewChangesets =
userInfo[YapDatabaseExtensionsKey] as? NSDictionary else {
// No changes for any views, skip.
continue
}
guard let changeset =
viewChangesets[viewName] as? NSDictionary else {
// No changes for this view, skip.
continue
}
// This constant matches a private constant in YDB.
let changeset_key_changes: String = "changes"
guard let changesetChanges = changeset[changeset_key_changes] as? [Any] else {
owsFailDebug("Missing changeset changes.")
continue
}
for change in changesetChanges {
if change as? YapDatabaseViewSectionChange != nil {
// Ignore.
} else if let rowChange = change as? YapDatabaseViewRowChange {
updatedItemIds.insert(rowChange.collectionKey.key)
} else {
owsFailDebug("Invalid change: \(type(of: change)).")
continue
}
}
}
return updatedItemIds
}
}

@ -4,23 +4,26 @@ import UIKit
import SignalUtilitiesKit
public class ConversationSearchController: NSObject {
public static let kMinimumSearchTextLength: UInt = 2
public static let minimumSearchTextLength: UInt = 2
private let threadId: String
public weak var delegate: ConversationSearchControllerDelegate?
public let uiSearchController: UISearchController = UISearchController(searchResultsController: nil)
public let resultsBar: SearchResultsBar = SearchResultsBar()
// MARK: Initializer
override public init() {
public init(threadId: String) {
self.threadId = threadId
super.init()
resultsBar.resultsBarDelegate = self
uiSearchController.delegate = self
uiSearchController.searchResultsUpdater = self
self.resultsBar.resultsBarDelegate = self
self.uiSearchController.delegate = self
self.uiSearchController.searchResultsUpdater = self
uiSearchController.hidesNavigationBarDuringPresentation = false
uiSearchController.searchBar.inputAccessoryView = resultsBar
self.uiSearchController.hidesNavigationBarDuringPresentation = false
self.uiSearchController.searchBar.inputAccessoryView = resultsBar
}
}
@ -28,12 +31,10 @@ public class ConversationSearchController: NSObject {
extension ConversationSearchController: UISearchControllerDelegate {
public func didPresentSearchController(_ searchController: UISearchController) {
Logger.verbose("")
delegate?.didPresentSearchController?(searchController)
}
public func didDismissSearchController(_ searchController: UISearchController) {
Logger.verbose("")
delegate?.didDismissSearchController?(searchController)
}
}
@ -41,39 +42,30 @@ extension ConversationSearchController: UISearchControllerDelegate {
// MARK: - UISearchResultsUpdating
extension ConversationSearchController: UISearchResultsUpdating {
var dbSearcher: FullTextSearcher {
return FullTextSearcher.shared
}
public func updateSearchResults(for searchController: UISearchController) {
Logger.verbose("searchBar.text: \( searchController.searchBar.text ?? "<blank>")")
guard let rawSearchText = searchController.searchBar.text?.stripped else {
self.resultsBar.updateResults(resultSet: nil)
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil)
guard
let searchText: String = searchController.searchBar.text?.stripped,
searchText.count >= ConversationSearchController.minimumSearchTextLength
else {
self.resultsBar.updateResults(results: nil)
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil, searchText: nil)
return
}
let searchText = FullTextSearchFinder.normalize(text: rawSearchText)
guard searchText.count >= ConversationSearchController.kMinimumSearchTextLength else {
self.resultsBar.updateResults(resultSet: nil)
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil)
return
let threadId: String = self.threadId
let results: [Int64] = GRDBStorage.shared.read { db -> [Int64] in
try Interaction.idsForTermWithin(
threadId: threadId,
pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText)
)
.fetchAll(db)
}
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)
})
.defaulting(to: [])
self.resultsBar.updateResults(results: results)
self.delegate?.conversationSearchController(self, didUpdateSearchResults: results, searchText: searchText)
}
}
@ -83,15 +75,11 @@ extension ConversationSearchController: SearchResultsBarDelegate {
func searchResultsBar(
_ searchResultsBar: SearchResultsBar,
setCurrentIndex currentIndex: Int,
resultSet: ConversationScreenSearchResultSet
results: [Int64]
) {
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, didSelectInteractionId: searchResult.messageId)
guard let interactionId: Int64 = results[safe: currentIndex] else { return }
self.delegate?.conversationSearchController(self, didSelectInteractionId: interactionId)
}
}
@ -99,12 +87,12 @@ protocol SearchResultsBarDelegate: AnyObject {
func searchResultsBar(
_ searchResultsBar: SearchResultsBar,
setCurrentIndex currentIndex: Int,
resultSet: ConversationScreenSearchResultSet
results: [Int64]
)
}
public final class SearchResultsBar: UIView {
private var resultSet: ConversationScreenSearchResultSet?
private var results: [Int64]?
var currentIndex: Int?
weak var resultsBarDelegate: SearchResultsBarDelegate?
@ -112,7 +100,6 @@ public final class SearchResultsBar: UIView {
private lazy var label: UILabel = {
let result = UILabel()
result.text = "Test"
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
result.textColor = Colors.text
return result
@ -136,6 +123,14 @@ public final class SearchResultsBar: UIView {
return result
}()
private lazy var loadingIndicator: UIActivityIndicatorView = {
let result = UIActivityIndicatorView(style: .medium)
result.tintColor = Colors.text
result.alpha = 0.5
result.hidesWhenStopped = true
return result
}()
override init(frame: CGRect) {
super.init(frame: frame)
setUpViewHierarchy()
@ -148,6 +143,7 @@ public final class SearchResultsBar: UIView {
private func setUpViewHierarchy() {
autoresizingMask = .flexibleHeight
// Background & blur
let backgroundView = UIView()
backgroundView.backgroundColor = isLightMode ? .white : .black
@ -157,18 +153,22 @@ public final class SearchResultsBar: UIView {
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
addSubview(blurView)
blurView.pin(to: self)
// Separator
let separator = UIView()
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
separator.set(.height, to: 1 / UIScreen.main.scale)
addSubview(separator)
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
// Spacers
let spacer1 = UIView.hStretchingSpacer()
let spacer2 = UIView.hStretchingSpacer()
// Button containers
let upButtonContainer = UIView(wrapping: upButton, withInsets: UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0))
let downButtonContainer = UIView(wrapping: downButton, withInsets: UIEdgeInsets(top: 0, left: 0, bottom: 2, right: 0))
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ upButtonContainer, downButtonContainer, spacer1, label, spacer2 ])
mainStackView.axis = .horizontal
@ -176,117 +176,113 @@ public final class SearchResultsBar: UIView {
mainStackView.isLayoutMarginsRelativeArrangement = true
mainStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.largeSpacing, bottom: Values.smallSpacing, trailing: Values.largeSpacing)
addSubview(mainStackView)
mainStackView.pin(.top, to: .bottom, of: separator)
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
mainStackView.pin(.bottom, to: .bottom, of: self, withInset: -2)
addSubview(loadingIndicator)
loadingIndicator.pin(.left, to: .right, of: label, withInset: 10)
loadingIndicator.centerYAnchor.constraint(equalTo: label.centerYAnchor).isActive = true
// Remaining constraints
label.center(.horizontal, in: self)
}
// MARK: - Functions
@objc
public func handleUpButtonTapped() {
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
}
guard let results: [Int64] = results else { return }
guard let currentIndex: Int = currentIndex else { return }
guard currentIndex + 1 < results.count else { return }
let newIndex = currentIndex + 1
self.currentIndex = newIndex
updateBarItems()
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet)
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results)
}
@objc
public func handleDownButtonTapped() {
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
}
guard let results: [Int64] = results else { return }
guard let currentIndex: Int = currentIndex, currentIndex > 0 else { return }
let newIndex = currentIndex - 1
self.currentIndex = newIndex
updateBarItems()
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet)
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results)
}
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 {
func updateResults(results: [Int64]?) {
if let results: [Int64] = results, !results.isEmpty {
currentIndex = min(currentIndex ?? 0, results.count - 1)
}
else {
currentIndex = nil
}
self.resultSet = resultSet
self.results = results
updateBarItems()
if let currentIndex = currentIndex, let resultSet = resultSet {
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: currentIndex, resultSet: resultSet)
if let currentIndex = currentIndex, let results = results {
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: currentIndex, results: results)
}
}
func updateBarItems() {
guard let resultSet = resultSet else {
guard let results: [Int64] = results else {
label.text = ""
downButton.isEnabled = false
upButton.isEnabled = false
return
}
switch resultSet.messages.count {
case 0:
label.text = NSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS", comment: "keyboard toolbar label when no messages match the search string")
case 1:
label.text = 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
switch results.count {
case 0:
// Keyboard toolbar label when no messages match the search string
label.text = "CONVERSATION_SEARCH_NO_RESULTS".localized()
case 1:
// Keyboard toolbar label when exactly 1 message matches the search string
label.text = "CONVERSATION_SEARCH_ONE_RESULT".localized()
default:
// Keyboard toolbar label when more than 1 message matches the search string
//
// Embeds {{number/position of the 'currently viewed' result}} and
// the {{total number of results}}
let format = "CONVERSATION_SEARCH_RESULTS_FORMAT".localized()
guard let currentIndex: Int = currentIndex else { return }
label.text = String(format: format, currentIndex + 1, results.count)
}
label.text = String(format: format, currentIndex + 1, resultSet.messages.count)
}
if let currentIndex = currentIndex {
if let currentIndex: Int = currentIndex {
downButton.isEnabled = currentIndex > 0
upButton.isEnabled = currentIndex + 1 < resultSet.messages.count
} else {
upButton.isEnabled = (currentIndex + 1 < results.count)
}
else {
downButton.isEnabled = false
upButton.isEnabled = false
}
}
public func startLoading() {
loadingIndicator.startAnimating()
}
public func stopLoading() {
loadingIndicator.stopAnimating()
}
}
// MARK: - ConversationSearchControllerDelegate
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?)
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?)
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId: Int64)
}

@ -14,24 +14,28 @@ import SignalUtilitiesKit
// Remaining search glitchiness
final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate {
private static let loadingHeaderHeight: CGFloat = 20
internal let viewModel: ConversationViewModel
private var dataChangeObservable: DatabaseCancellable?
private var hasLoadedInitialData: Bool = false
private var hasLoadedInitialThreadData: Bool = false
private var hasLoadedInitialInteractionData: Bool = false
private var currentTargetOffset: CGPoint?
private var isAutoLoadingNextPage: Bool = false
private var isLoadingMore: Bool = false
/// This flag indicates whether the data has been reloaded after a disappearance (it defaults to true as it will never
/// have disappeared before)
private var hasReloadedDataAfterDisappearance: Bool = true
/// This flag indicates whether the thread data has been reloaded after a disappearance (it defaults to true as it will
/// never have disappeared before - this is only needed for value observers since they run asynchronously)
private var hasReloadedThreadDataAfterDisappearance: Bool = true
var focusedMessageIndexPath: IndexPath?
var initialUnreadCount: UInt = 0
var unreadViewItems: [ConversationViewItem] = []
var focusedInteractionId: Int64?
var shouldHighlightNextScrollToInteraction: Bool = false
var scrollButtonBottomConstraint: NSLayoutConstraint?
var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint?
var messageRequestsViewBotomConstraint: NSLayoutConstraint?
// Search
var isShowingSearchUI = false
var lastSearchedText: String?
// Audio playback & recording
var audioPlayer: OWSAudioPlayer?
@ -49,7 +53,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
// Scrolling & paging
var isUserScrolling = false
var didFinishInitialLayout = false
var isLoadingMore = false
var scrollDistanceToBottomBeforeUpdate: CGFloat?
var baselineKeyboardHeight: CGFloat = 0
@ -105,7 +108,9 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
lazy var recordVoiceMessageActivity = AudioActivity(audioDescription: "Voice message", behavior: .playAndRecord)
lazy var searchController: ConversationSearchController = {
let result: ConversationSearchController = ConversationSearchController()
let result: ConversationSearchController = ConversationSearchController(
threadId: self.viewModel.threadData.threadId
)
result.uiSearchController.obscuresBackgroundDuringPresentation = false
result.delegate = self
@ -140,6 +145,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
bottom: Values.mediumSpacing,
trailing: 0
)
result.registerHeaderFooterView(view: UITableViewHeaderFooterView.self)
result.register(view: VisibleMessageCell.self)
result.register(view: InfoMessageCell.self)
result.register(view: TypingIndicatorCell.self)
@ -305,6 +311,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
}
self.viewModel = viewModel
GRDBStorage.shared.addObserver(viewModel.pagedDataObserver)
super.init(nibName: nil, bundle: nil)
}
@ -426,31 +433,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
// Update the input state
snInputView.setEnabledMessageTypes(viewModel.threadData.enabledMessageTypes, message: nil)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard !didFinishInitialLayout else { return }
// Scroll to the last unread message if possible; otherwise scroll to the bottom.
// When the unread message count is more than the number of view items of a page,
// the screen will scroll to the bottom instead of the first unread message
DispatchQueue.main.async {
if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId {
self.scrollToInteraction(with: focusedInteractionId, isAnimated: false, highlighted: true)
}
else if let firstUnreadInteractionId: Int64 = self.viewModel.threadData.threadFirstUnreadInteractionId {
self.scrollToInteraction(with: firstUnreadInteractionId, position: .top, isAnimated: false)
self.unreadCountView.alpha = self.scrollButton.alpha
}
else {
self.scrollToBottom(isAnimated: false)
}
self.scrollButton.alpha = self.getScrollButtonOpacity()
}
}
override func viewWillAppear(_ animated: Bool) {
@ -462,15 +444,17 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
highlightFocusedMessageIfNeeded()
didFinishInitialLayout = true
viewModel.markAllAsRead()
if delayFirstResponder {
if delayFirstResponder || isShowingSearchUI {
delayFirstResponder = false
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in
self?.becomeFirstResponder()
(self?.isShowingSearchUI == false ?
self :
self?.searchController.uiSearchController.searchBar
)?.becomeFirstResponder()
}
}
}
@ -487,7 +471,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
super.viewDidDisappear(animated)
mediaCache.removeAllObjects()
hasReloadedDataAfterDisappearance = false
hasReloadedThreadDataAfterDisappearance = false
}
@objc func applicationDidBecomeActive(_ notification: Notification) {
@ -510,6 +494,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
// The default scheduler emits changes on the main thread
self?.handleThreadUpdates(threadData)
self?.performInitialScrollIfNeeded()
}
)
@ -527,9 +512,9 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
private func handleThreadUpdates(_ updatedThreadData: ConversationCell.ViewModel, initialLoad: Bool = false) {
// Ensure the first load or a load when returning from a child screen runs without animations (if
// we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition)
guard hasLoadedInitialData && hasReloadedDataAfterDisappearance else {
hasLoadedInitialData = true
hasReloadedDataAfterDisappearance = true
guard hasLoadedInitialThreadData && hasReloadedThreadDataAfterDisappearance else {
hasLoadedInitialThreadData = true
hasReloadedThreadDataAfterDisappearance = true
UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) }
return
}
@ -578,27 +563,159 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
}
}
private func handleInteractionUpdates(_ updatedViewData: [MessageCell.ViewModel], initialLoad: Bool = false) {
private func handleInteractionUpdates(_ updatedData: [ConversationViewModel.SectionModel], initialLoad: Bool = false) {
// Ensure the first load or a load when returning from a child screen runs without animations (if
// we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition)
guard hasLoadedInitialData && hasReloadedDataAfterDisappearance else {
hasLoadedInitialData = true
hasReloadedDataAfterDisappearance = true
UIView.performWithoutAnimation { handleInteractionUpdates(updatedViewData, initialLoad: true) }
guard self.hasLoadedInitialInteractionData else {
self.hasLoadedInitialInteractionData = true
self.viewModel.updateInteractionData(updatedData)
UIView.performWithoutAnimation {
self.tableView.reloadData()
self.performInitialScrollIfNeeded()
}
return
}
// Reload the table content (animate changes after the first load)
let changeset = StagedChangeset(source: viewModel.interactionData, target: updatedViewData)
tableView.reload(
using: StagedChangeset(source: viewModel.interactionData, target: updatedViewData),
deleteSectionsAnimation: .bottom,
insertSectionsAnimation: .bottom,
// Determine if we are inserting content at the top of the collectionView
struct ItemChangeInfo {
let insertedAtTop: Bool
let firstIndexIsVisible: Bool
let visibleInteractionId: Int64
let visibleIndexPath: IndexPath
let oldVisibleIndexPath: IndexPath
init(
insertedAtTop: Bool,
firstIndexIsVisible: Bool = false,
visibleInteractionId: Int64 = -1,
visibleIndexPath: IndexPath = IndexPath(row: 0, section: 0),
oldVisibleIndexPath: IndexPath = IndexPath(row: 0, section: 0)
) {
self.insertedAtTop = insertedAtTop
self.firstIndexIsVisible = firstIndexIsVisible
self.visibleInteractionId = visibleInteractionId
self.visibleIndexPath = visibleIndexPath
self.oldVisibleIndexPath = oldVisibleIndexPath
}
}
let itemChangeInfo: ItemChangeInfo = {
guard
let oldSectionIndex: Int = self.viewModel.interactionData.firstIndex(where: { $0.model == .messages }),
let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }),
let newFirstItemIndex: Int = updatedData[newSectionIndex].elements
.firstIndex(where: { item -> Bool in
item.id == self.viewModel.interactionData[oldSectionIndex].elements.first?.id
}),
let firstVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows?
.filter({ $0.section == oldSectionIndex })
.sorted()
.first,
let newVisibleIndex: Int = updatedData[newSectionIndex].elements
.firstIndex(where: { item in
item.id == self.viewModel.interactionData[oldSectionIndex]
.elements[firstVisibleIndexPath.row]
.id
}),
(
newSectionIndex > oldSectionIndex ||
newFirstItemIndex > 0
)
else { return ItemChangeInfo(insertedAtTop: false) }
return ItemChangeInfo(
insertedAtTop: true,
firstIndexIsVisible: (firstVisibleIndexPath.row == 0),
visibleInteractionId: updatedData[newSectionIndex].elements[newVisibleIndex].id,
visibleIndexPath: IndexPath(row: newVisibleIndex, section: newSectionIndex),
oldVisibleIndexPath: firstVisibleIndexPath
)
}()
/// If we are inserting at the top then we want to maintain the same visual position from before the table view was updated,
/// unfortunately the UITableView does some weird things when updating (where it won't have updated data until after it
/// performs the next layout); the below code checks a condition on layout and if it passes it calls a closure
///
/// In the below case we set the tableView offset of the first row to the same offset it had before the UI loaded with new
/// data (including the difference in height in case the date header was removed when loading the new cell)
if itemChangeInfo.insertedAtTop {
let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count }
let cellSorting: (MessageCell, MessageCell) -> Bool = { lhs, rhs -> Bool in
if !lhs.isHidden && rhs.isHidden { return true }
if lhs.isHidden && !rhs.isHidden { return false }
return (lhs.frame.minY < rhs.frame.minY)
}
let oldRect: CGRect = (self.tableView.subviews
.compactMap { $0 as? MessageCell }
.sorted(by: cellSorting)
.first(where: { cell -> Bool in cell.viewModel?.id == itemChangeInfo.visibleInteractionId })?
.frame)
.defaulting(to: self.tableView.rectForRow(at: itemChangeInfo.oldVisibleIndexPath))
let oldContentSize: CGSize = self.tableView.contentSize
let oldContentOffset: CGPoint = self.tableView.contentOffset
// Distance of 64 when paging works properly
self.tableView.afterNextLayoutSubviews(
when: { numSections, numRowsInSections -> Bool in
numSections == updatedData.count &&
numRowsInSections == numItemsInUpdatedData
},
then: { [weak self] in
self?.tableView.scrollToRow(at: itemChangeInfo.visibleIndexPath, at: .top, animated: false)
self?.tableView.layoutIfNeeded()
/// **Note:** I wasn't able to get a prober equation to handle both "insert above first item" and "insert
/// at top off screen", it seems that the 'contentOffset' value won't expose negative values (eg. when you
/// over-scroll and trigger the bounce effect) and this results in requiring the conditional logic below
if itemChangeInfo.firstIndexIsVisible {
let newRect: CGRect = (self?.tableView.subviews
.compactMap { $0 as? MessageCell }
.sorted(by: cellSorting)
.first(where: { $0.viewModel?.id == itemChangeInfo.visibleInteractionId })?
.frame)
.defaulting(to: oldRect)
let heightDiff: CGFloat = (oldRect.height - newRect.height)
self?.tableView.contentOffset.y = (newRect.minY - (oldRect.minY + heightDiff))
}
else {
let newContentSize: CGSize = (self?.tableView.contentSize)
.defaulting(to: oldContentSize)
let contentSizeDiff: CGFloat = (newContentSize.height - oldContentSize.height)
self?.tableView.contentOffset.y = (contentSizeDiff + oldContentOffset.y)
}
if let focusedInteractionId: Int64 = self?.focusedInteractionId {
DispatchQueue.main.async {
self?.searchController.resultsBar.stopLoading()
self?.scrollToInteractionIfNeeded(
with: focusedInteractionId,
isAnimated: true,
highlight: (self?.shouldHighlightNextScrollToInteraction == true)
)
}
}
// Complete page loading
self?.isLoadingMore = false
self?.autoLoadNextPageIfNeeded()
}
)
}
// Reload the table content (animate changes if we aren't inserting at the top)
self.tableView.reload(
using: StagedChangeset(source: viewModel.interactionData, target: updatedData),
deleteSectionsAnimation: .none,
insertSectionsAnimation: .none,
reloadSectionsAnimation: .none,
deleteRowsAnimation: .bottom,
insertRowsAnimation: .bottom,
reloadRowsAnimation: .none,
interrupt: { $0.changeCount > ConversationViewModel.pageSize }
interrupt: { itemChangeInfo.insertedAtTop || $0.changeCount > ConversationViewModel.pageSize }
) { [weak self] updatedData in
self?.viewModel.updateInteractionData(updatedData)
}
@ -619,6 +736,76 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
viewModel.sentMessageBeforeUpdate = false
}
private func performInitialScrollIfNeeded() {
guard !didFinishInitialLayout && hasLoadedInitialThreadData && hasLoadedInitialInteractionData else { return }
// Scroll to the last unread message if possible; otherwise scroll to the bottom.
// When the unread message count is more than the number of view items of a page,
// the screen will scroll to the bottom instead of the first unread message
DispatchQueue.main.async {
if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId {
self.scrollToInteractionIfNeeded(with: focusedInteractionId, isAnimated: false, highlight: true)
}
else if let firstUnreadInteractionId: Int64 = self.viewModel.threadData.threadFirstUnreadInteractionId {
self.scrollToInteractionIfNeeded(with: firstUnreadInteractionId, position: .top, isAnimated: false)
self.unreadCountView.alpha = self.scrollButton.alpha
}
else {
self.scrollToBottom(isAnimated: false)
}
self.scrollButton.alpha = self.getScrollButtonOpacity()
// Now that the data has loaded we need to check if either of the "load more" sections are
// visible and trigger them if so
//
// Note: We do it this way as we want to trigger the load behaviour for the first section
// if it has one before trying to trigger the load behaviour for the last section
self.autoLoadNextPageIfNeeded()
}
}
private func autoLoadNextPageIfNeeded() {
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
self.isAutoLoadingNextPage = true
DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
self?.isAutoLoadingNextPage = false
// Note: We sort the headers as we want to prioritise loading newer pages over older ones
let sections: [(ConversationViewModel.Section, CGRect)] = (self?.viewModel.interactionData
.enumerated()
.map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: 0) ?? .zero)) })
.defaulting(to: [])
let shouldLoadOlder: Bool = sections
.contains { section, headerRect in
section == .loadOlder &&
headerRect != .zero &&
(self?.tableView.bounds.contains(headerRect) == true)
}
let shouldLoadNewer: Bool = sections
.contains { section, headerRect in
section == .loadNewer &&
headerRect != .zero &&
(self?.tableView.bounds.contains(headerRect) == true)
}
guard shouldLoadOlder || shouldLoadNewer else { return }
self?.isLoadingMore = true
DispatchQueue.global(qos: .default).async { [weak self] in
// Attachments are loaded in descending order so 'loadOlder' actually corresponds with
// 'pageAfter' in this case
self?.viewModel.pagedDataObserver?.load(shouldLoadOlder ?
.pageAfter :
.pageBefore
)
}
}
}
func updateNavBarButtons(threadData: ConversationCell.ViewModel) {
navigationItem.hidesBackButton = isShowingSearchUI
@ -675,15 +862,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
}
}
// MARK: Notifications
private func highlightFocusedMessageIfNeeded() {
if let indexPath = focusedMessageIndexPath, let cell = tableView.cellForRow(at: indexPath) as? VisibleMessageCell {
cell.highlight()
focusedMessageIndexPath = nil
}
}
// MARK: - Notifications
@objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) {
// Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600
@ -777,113 +956,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
completion: nil
)
}
func conversationViewModelWillUpdate() {
// Not currently in use
}
func conversationViewModelDidUpdate(_ conversationUpdate: ConversationUpdate) {
guard self.isViewLoaded else { return }
let updateType = conversationUpdate.conversationUpdateType
guard updateType != .minor else { return } // No view items were affected
if updateType == .reload {
if threadStartedAsMessageRequest {
updateNavBarButtons() // In case the message request was approved
}
return messagesTableView.reloadData()
}
var shouldScrollToBottom = false
let batchUpdates: () -> Void = {
for update in conversationUpdate.updateItems! {
switch update.updateItemType {
case .delete:
self.messagesTableView.deleteRows(at: [ IndexPath(row: Int(update.oldIndex), section: 0) ], with: .none)
case .insert:
// Perform inserts before updates
self.messagesTableView.insertRows(at: [ IndexPath(row: Int(update.newIndex), section: 0) ], with: .none)
if update.viewItem?.interaction is TSOutgoingMessage {
shouldScrollToBottom = true
} else {
shouldScrollToBottom = self.isCloseToBottom
}
case .update:
self.messagesTableView.reloadRows(at: [ IndexPath(row: Int(update.oldIndex), section: 0) ], with: .none)
default: preconditionFailure()
}
// Update the nav items if the message request was approved
if (update.viewItem?.interaction as? TSInfoMessage)?.messageType == .messageRequestAccepted {
self.updateNavBarButtons()
}
}
}
UIView.performWithoutAnimation {
messagesTableView.performBatchUpdates(batchUpdates) { _ in
if shouldScrollToBottom {
self.scrollToBottom(isAnimated: false)
}
self.markAllAsRead()
}
}
// Update the input state if this is a contact thread
if let contactThread: TSContactThread = thread as? TSContactThread {
let contact: Contact? = GRDBStorage.shared.read { db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) }
// If the contact doesn't exist yet then it's a message request without the first message sent
// so only allow text-based messages
self.snInputView.setEnabledMessageTypes(
(thread.isNoteToSelf() || contact?.didApproveMe == true || thread.isMessageRequest() ?
.all : .textOnly
),
message: nil
)
}
}
func conversationViewModelWillLoadMoreItems() {
view.layoutIfNeeded()
// The scroll distance to bottom will be restored in conversationViewModelDidLoadMoreItems
scrollDistanceToBottomBeforeUpdate = messagesTableView.contentSize.height - messagesTableView.contentOffset.y
}
func conversationViewModelDidLoadMoreItems() {
guard let scrollDistanceToBottomBeforeUpdate = scrollDistanceToBottomBeforeUpdate else { return }
view.layoutIfNeeded()
messagesTableView.contentOffset.y = messagesTableView.contentSize.height - scrollDistanceToBottomBeforeUpdate
isLoadingMore = false
}
func conversationViewModelDidLoadPrevPage() {
// Not currently in use
}
func conversationViewModelRangeDidChange() {
// Not currently in use
}
func conversationViewModelDidReset() {
// Not currently in use
}
@objc private func handleMessageSentStatusChanged() {
DispatchQueue.main.async {
guard let indexPaths = self.tableView.indexPathsForVisibleRows else { return }
var indexPathsToReload: [IndexPath] = []
for indexPath in indexPaths {
guard let cell = self.tableView.cellForRow(at: indexPath) as? VisibleMessageCell else { continue }
let isLast = (indexPath.item == (self.tableView.numberOfRows(inSection: 0) - 1))
guard !isLast else { continue }
if !cell.messageStatusImageView.isHidden {
indexPathsToReload.append(indexPath)
}
}
UIView.performWithoutAnimation {
self.tableView.reloadRows(at: indexPathsToReload, with: .none)
}
}
}
// MARK: - General
@ -899,31 +971,64 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
// MARK: - UITableViewDataSource
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
func numberOfSections(in tableView: UITableView) -> Int {
return viewModel.interactionData.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let section: ConversationViewModel.SectionModel = viewModel.interactionData[section]
return section.elements.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cellViewModel: MessageCell.ViewModel = viewModel.interactionData[indexPath.row]
let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: cellViewModel), for: indexPath)
cell.update(
with: cellViewModel,
mediaCache: mediaCache,
playbackInfo: viewModel.playbackInfo(for: cellViewModel) { updatedInfo, error in
DispatchQueue.main.async {
guard error == nil else {
OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized())
return
}
cell.dynamicUpdate(with: cellViewModel, playbackInfo: updatedInfo)
}
},
lastSearchText: viewModel.lastSearchedText
)
cell.delegate = self
let section: ConversationViewModel.SectionModel = viewModel.interactionData[indexPath.section]
return cell
switch section.model {
case .messages:
let cellViewModel: MessageCell.ViewModel = section.elements[indexPath.row]
let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: cellViewModel), for: indexPath)
cell.update(
with: cellViewModel,
mediaCache: mediaCache,
playbackInfo: viewModel.playbackInfo(for: cellViewModel) { updatedInfo, error in
DispatchQueue.main.async {
guard error == nil else {
OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized())
return
}
// TODO: Looks like the 'play/pause' icon isn't swapping when it auto-plays to the next item)
cell.dynamicUpdate(with: cellViewModel, playbackInfo: updatedInfo)
}
},
lastSearchText: viewModel.lastSearchedText
)
cell.delegate = self
return cell
default: preconditionFailure("Other sections should have no content")
}
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let section: ConversationViewModel.SectionModel = viewModel.interactionData[section]
switch section.model {
case .loadOlder, .loadNewer:
let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
loadingIndicator.tintColor = Colors.text
loadingIndicator.alpha = 0.5
loadingIndicator.startAnimating()
let view: UIView = UIView()
view.addSubview(loadingIndicator)
loadingIndicator.center(in: view)
return view
case .messages: return nil
}
}
// MARK: - UITableViewDelegate
@ -935,6 +1040,37 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableView.automaticDimension
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let section: ConversationViewModel.SectionModel = viewModel.interactionData[section]
switch section.model {
case .loadOlder, .loadNewer: return ConversationVC.loadingHeaderHeight
case .messages: return 0
}
}
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
guard self.didFinishInitialLayout && !self.isLoadingMore else { return }
let section: ConversationViewModel.SectionModel = self.viewModel.interactionData[section]
switch section.model {
case .loadOlder, .loadNewer:
self.isLoadingMore = true
DispatchQueue.global(qos: .default).async { [weak self] in
// Messages are loaded in descending order so 'loadOlder' actually corresponds with
// 'pageAfter' in this case
self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ?
.pageAfter :
.pageBefore
)
}
case .messages: break
}
}
func scrollToBottom(isAnimated: Bool) {
guard
@ -968,6 +1104,18 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
scrollButton.alpha = getScrollButtonOpacity()
unreadCountView.alpha = scrollButton.alpha
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
guard
let focusedInteractionId: Int64 = self.focusedInteractionId,
self.shouldHighlightNextScrollToInteraction
else {
self.focusedInteractionId = nil
return
}
self.highlightCellIfNeeded(interactionId: focusedInteractionId)
}
func updateUnreadCountView(unreadCount: UInt?) {
let unreadCount: Int = Int(unreadCount ?? 0)
@ -988,20 +1136,14 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
func conversationSettingsDidRequestConversationSearch(_ conversationSettingsViewController: OWSConversationSettingsViewController) {
showSearchUI()
popAllConversationSettingsViews {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // Without this delay the search bar doesn't show
self.searchController.uiSearchController.searchBar.becomeFirstResponder()
}
guard presentedViewController != nil else {
self.navigationController?.popToViewController(self, animated: true, completion: nil)
return
}
}
func popAllConversationSettingsViews(completion completionBlock: (() -> Void)? = nil) {
if presentedViewController != nil {
dismiss(animated: true) {
self.navigationController!.popToViewController(self, animated: true, completion: completionBlock)
}
} else {
navigationController!.popToViewController(self, animated: true, completion: completionBlock)
dismiss(animated: true) {
self.navigationController?.popToViewController(self, animated: true, completion: nil)
}
}
@ -1052,8 +1194,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
navigationItem.titleView = titleView
updateNavBarButtons(threadData: self.viewModel.threadData)
let navBar = navigationController!.navigationBar as! OWSNavigationBar
navBar.stubbedNextResponder = nil
let navBar: OWSNavigationBar? = navigationController?.navigationBar as? OWSNavigationBar
navBar?.stubbedNextResponder = nil
becomeFirstResponder()
reloadInputViews()
}
@ -1062,43 +1204,89 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
hideSearchUI()
}
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?) {
lastSearchedText = resultSet?.searchText
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?) {
tableView.reloadRows(at: tableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none)
}
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId interactionId: Int64) {
scrollToInteraction(with: interactionId)
scrollToInteractionIfNeeded(with: interactionId, highlight: true)
}
func scrollToInteraction(
func scrollToInteractionIfNeeded(
with interactionId: Int64,
position: UITableView.ScrollPosition = .middle,
isAnimated: Bool = true,
highlighted: Bool = false
highlight: Bool = false
) {
// Ensure the interaction is loaded
self.viewModel.pagedDataObserver?.load(.untilInclusive(id: interactionId, padding: 0))
// Store the info incase we need to load more data (call will be re-triggered)
self.focusedInteractionId = interactionId
self.shouldHighlightNextScrollToInteraction = highlight
// Ensure the target interaction has been loaded
guard
let messageSectionIndex: Int = self.viewModel.interactionData
.firstIndex(where: { $0.model == .messages }),
let targetMessageIndex = self.viewModel.interactionData[messageSectionIndex]
.elements
.firstIndex(where: { $0.id == interactionId })
else { return }
tableView.scrollToRow(
at: IndexPath(
row: targetMessageIndex,
section: messageSectionIndex
),
at: position,
animated: isAnimated
else {
// If not the make sure we have finished the initial layout before trying to
// load the up until the specified interaction
guard self.didFinishInitialLayout else { return }
self.searchController.resultsBar.startLoading()
DispatchQueue.global(qos: .default).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.untilInclusive(
id: interactionId,
padding: 5
))
}
return
}
let targetIndexPath: IndexPath = IndexPath(
row: targetMessageIndex,
section: messageSectionIndex
)
if highlighted {
focusedMessageId = interactionId
// If we aren't animating or aren't highlighting then everything can be run immediately
guard isAnimated && highlight else {
self.tableView.scrollToRow(at: targetIndexPath, at: position, animated: isAnimated)
self.focusedInteractionId = nil
self.shouldHighlightNextScrollToInteraction = false
if highlight {
self.highlightCellIfNeeded(interactionId: interactionId)
}
return
}
// If we are animating and highlighting then determine if we want to scroll to the target
// cell (if we try to trigger the `scrollToRow` call and the animation doesn't occur then
// the highlight will not be triggered so if a cell is entirely on the screen then just
// don't bother scrolling)
let targetRect: CGRect = self.tableView.rectForRow(at: targetIndexPath)
guard !self.tableView.bounds.contains(targetRect) else {
self.highlightCellIfNeeded(interactionId: interactionId)
return
}
self.tableView.scrollToRow(at: targetIndexPath, at: position, animated: true)
}
func highlightCellIfNeeded(interactionId: Int64) {
self.shouldHighlightNextScrollToInteraction = false
self.focusedInteractionId = nil
// Trigger on the next run loop incase we are still finishing some other animation
DispatchQueue.main.async {
self.tableView
.visibleCells
.first(where: { ($0 as? VisibleMessageCell)?.viewModel?.id == interactionId })
.asType(VisibleMessageCell.self)?
.highlight(interactionId: interactionId)
}
}
}

@ -1,157 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import <SessionMessagingKit/OWSAudioPlayer.h>
NS_ASSUME_NONNULL_BEGIN
extern NSString *const SNAudioDidFinishPlayingNotification;
typedef NS_ENUM(NSInteger, OWSMessageCellType) {
OWSMessageCellType_Unknown,
OWSMessageCellType_TextOnlyMessage,
OWSMessageCellType_Audio,
OWSMessageCellType_GenericAttachment,
OWSMessageCellType_MediaMessage,
OWSMessageCellType_OversizeTextDownloading,
OWSMessageCellType_DeletedMessage
};
NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
#pragma mark -
@class ContactShareViewModel;
@class ConversationViewCell;
@class DisplayableText;
@class YapDatabaseReadTransaction;
@interface ConversationMediaAlbumItem : NSObject
@property (nonatomic, readonly) TSAttachment *attachment;
// This property will only be set if the attachment is downloaded.
@property (nonatomic, readonly, nullable) TSAttachmentStream *attachmentStream;
// This property will be non-zero if the attachment is valid.
@property (nonatomic, readonly) CGSize mediaSize;
@property (nonatomic, readonly, nullable) NSString *caption;
@property (nonatomic, readonly) BOOL isFailedDownload;
@end
#pragma mark -
@protocol ConversationViewItem <NSObject, OWSAudioPlayerDelegate>
@property (nonatomic, readonly) TSInteraction *interaction;
@property (nonatomic, readonly, nullable) OWSQuotedReplyModel *quotedReply;
@property (nonatomic, readonly) BOOL isGroupThread;
@property (nonatomic, readonly) BOOL userCanDeleteGroupMessage;
@property (nonatomic, readonly) BOOL userHasModerationPermission;
@property (nonatomic, readonly) BOOL hasBodyText;
@property (nonatomic, readonly) BOOL isQuotedReply;
@property (nonatomic, readonly) BOOL hasQuotedAttachment;
@property (nonatomic, readonly) BOOL hasQuotedText;
@property (nonatomic, readonly) BOOL hasCellHeader;
@property (nonatomic, readonly) BOOL isExpiringMessage;
@property (nonatomic) BOOL shouldShowDate;
@property (nonatomic) BOOL shouldShowSenderProfilePicture;
@property (nonatomic, nullable) NSAttributedString *senderName;
@property (nonatomic) BOOL shouldHideFooter;
@property (nonatomic) BOOL isFirstInCluster;
@property (nonatomic) BOOL isOnlyMessageInCluster;
@property (nonatomic) BOOL isLastInCluster;
@property (nonatomic) BOOL wasPreviousItemInfoMessage;
@property (nonatomic, nullable) OWSUnreadIndicator *unreadIndicator;
- (void)replaceInteraction:(TSInteraction *)interaction transaction:(YapDatabaseReadTransaction *)transaction;
- (void)clearCachedLayoutState;
@property (nonatomic, readonly) BOOL hasCachedLayoutState;
#pragma mark - Audio Playback
@property (nonatomic, weak) SNVoiceMessageView *lastAudioMessageView;
@property (nonatomic, readonly) CGFloat audioDurationSeconds;
@property (nonatomic, readonly) CGFloat audioProgressSeconds;
#pragma mark - View State Caching
// These methods only apply to text & attachment messages.
@property (nonatomic, readonly) OWSMessageCellType messageCellType;
@property (nonatomic, readonly, nullable) DisplayableText *displayableBodyText;
@property (nonatomic, readonly, nullable) TSAttachmentStream *attachmentStream;
@property (nonatomic, readonly, nullable) TSAttachmentPointer *attachmentPointer;
@property (nonatomic, readonly, nullable) NSArray<ConversationMediaAlbumItem *> *mediaAlbumItems;
@property (nonatomic, readonly, nullable) DisplayableText *displayableQuotedText;
@property (nonatomic, readonly, nullable) NSString *quotedAttachmentMimetype;
@property (nonatomic, readonly, nullable) NSString *quotedRecipientId;
// We don't want to try to load the media for this item (if any)
// if a load has previously failed.
@property (nonatomic) BOOL didCellMediaFailToLoad;
@property (nonatomic, readonly, nullable) ContactShareViewModel *contactShare;
@property (nonatomic, readonly, nullable) OWSLinkPreview *linkPreview;
@property (nonatomic, readonly, nullable) TSAttachment *linkPreviewAttachment;
@property (nonatomic, readonly, nullable) NSString *systemMessageText;
// NOTE: This property is only set for incoming messages.
@property (nonatomic, readonly, nullable) NSString *authorConversationColorName;
#pragma mark - MessageActions
@property (nonatomic, readonly) BOOL hasBodyTextActionContent;
@property (nonatomic, readonly) BOOL hasMediaActionContent;
- (void)copyMediaAction;
- (void)copyTextAction;
- (void)shareMediaAction;
- (void)saveMediaAction;
- (void)deleteLocallyAction;
- (void)deleteRemotelyAction;
- (void)deleteAction; // Remove this after the unsend request is enabled
- (BOOL)canCopyMedia;
- (BOOL)canSaveMedia;
// For view items that correspond to interactions, this is the interaction's unique id.
// For other view views (like the typing indicator), this is a unique, stable string.
- (NSString *)itemId;
- (nullable TSAttachmentStream *)firstValidAlbumAttachment;
- (BOOL)mediaAlbumHasFailedAttachment;
@end
#pragma mark -
@interface ConversationInteractionViewItem
: NSObject <ConversationViewItem, OWSAudioPlayerDelegate>
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithInteraction:(TSInteraction *)interaction
isGroupThread:(BOOL)isGroupThread
transaction:(YapDatabaseReadTransaction *)transaction;
@end
NS_ASSUME_NONNULL_END

File diff suppressed because it is too large Load Diff

@ -1,142 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@class ConversationStyle;
@class ConversationViewModel;
@class OWSQuotedReplyModel;
@class TSOutgoingMessage;
@class TSThread;
@class ThreadDynamicInteractions;
@protocol ConversationViewItem;
typedef NS_ENUM(NSUInteger, ConversationUpdateType) {
// No view items in the load window were effected.
ConversationUpdateType_Minor,
// A subset of view items in the load window were effected;
// the view should be updated using the update items.
ConversationUpdateType_Diff,
// Complicated or unexpected changes occurred in the load window;
// the view should be reloaded.
ConversationUpdateType_Reload,
};
#pragma mark -
typedef NS_ENUM(NSUInteger, ConversationUpdateItemType) {
ConversationUpdateItemType_Insert,
ConversationUpdateItemType_Delete,
ConversationUpdateItemType_Update,
};
#pragma mark -
@interface ConversationViewState : NSObject
@property (nonatomic, readonly) NSArray<id<ConversationViewItem>> *viewItems;
@property (nonatomic, readonly) NSDictionary<NSString *, NSNumber *> *interactionIndexMap;
// We have to track interactionIds separately. We can't just use interactionIndexMap.allKeys,
// as that won't preserve ordering.
@property (nonatomic, readonly) NSArray<NSString *> *interactionIds;
@property (nonatomic, readonly, nullable) NSNumber *unreadIndicatorIndex;
@end
#pragma mark -
@interface ConversationUpdateItem : NSObject
@property (nonatomic, readonly) ConversationUpdateItemType updateItemType;
// Only applies in the "delete" and "update" cases.
@property (nonatomic, readonly) NSUInteger oldIndex;
// Only applies in the "insert" and "update" cases.
@property (nonatomic, readonly) NSUInteger newIndex;
// Only applies in the "insert" and "update" cases.
@property (nonatomic, readonly, nullable) id<ConversationViewItem> viewItem;
@end
#pragma mark -
@interface ConversationUpdate : NSObject
@property (nonatomic, readonly) ConversationUpdateType conversationUpdateType;
// Only applies in the "diff" case.
@property (nonatomic, readonly, nullable) NSArray<ConversationUpdateItem *> *updateItems;
//// Only applies in the "diff" case.
@property (nonatomic, readonly) BOOL shouldAnimateUpdates;
@end
#pragma mark -
@protocol ConversationViewModelDelegate <NSObject>
- (void)conversationViewModelWillUpdate;
- (void)conversationViewModelDidUpdate:(ConversationUpdate *)conversationUpdate;
- (void)conversationViewModelWillLoadMoreItems;
- (void)conversationViewModelDidLoadMoreItems;
- (void)conversationViewModelDidLoadPrevPage;
- (void)conversationViewModelRangeDidChange;
// Called after the view model recovers from a severe error
// to prod the view to reset its scroll state, etc.
- (void)conversationViewModelDidReset;
@end
#pragma mark -
// Always load up to n messages when user arrives.
//
// The smaller this number is, the faster the conversation can display.
// To test, shrink you accessibility font as much as possible, then count how many 1-line system info messages (our
// shortest cells) can fit on screen at a time on an iPhoneX
//
// PERF: we could do less messages on shorter (older, slower) devices
// PERF: we could cache the cell height, since some messages will be much taller.
static const int kYapDatabasePageSize = 250;
// Never show more than n messages in conversation view when user arrives.
static const int kConversationInitialMaxRangeSize = 250;
// Never show more than n messages in conversation view at a time.
static const int kYapDatabaseRangeMaxLength = 250000;
#pragma mark -
@interface ConversationViewModel : NSObject
@property (nonatomic, readonly) ConversationViewState *viewState;
@property (nonatomic, nullable) NSString *focusMessageIdOnOpen;
@property (nonatomic, readonly, nullable) ThreadDynamicInteractions *dynamicInteractions;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithThread:(TSThread *)thread
focusMessageIdOnOpen:(nullable NSString *)focusMessageIdOnOpen
delegate:(id<ConversationViewModelDelegate>)delegate NS_DESIGNATED_INITIALIZER;
- (void)ensureDynamicInteractionsAndUpdateIfNecessary;
- (void)loadAnotherPageOfMessages;
- (void)viewDidResetContentAndLayout;
- (void)viewDidLoad;
- (BOOL)canLoadMoreItems;
- (nullable NSIndexPath *)ensureLoadWindowContainsQuotedReply:(OWSQuotedReplyModel *)quotedReply;
- (nullable NSIndexPath *)ensureLoadWindowContainsInteractionId:(NSString *)interactionId;
- (void)appendUnsavedOutgoingTextMessage:(TSOutgoingMessage *)outgoingMessage;
- (BOOL)reloadViewItems;
@end
NS_ASSUME_NONNULL_END

File diff suppressed because it is too large Load Diff

@ -18,7 +18,6 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
case videoCall
}
public static let pageSize: Int = 50
// MARK: - Section
public enum Section: Differentiable, Equatable, Comparable, Hashable {
@ -29,6 +28,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// MARK: - Variables
public static let pageSize: Int = 50
// MARK: - Initialization

@ -383,9 +383,16 @@ public class MediaView: UIView {
Logger.verbose("Skipping obsolete load.")
return
}
DispatchQueue.main.async {
loadMediaBlock(loadCompletion)
loadMediaBlock { media in
guard Thread.isMainThread else {
DispatchQueue.main.async {
loadCompletion(media)
}
return
}
loadCompletion(media)
}
}
}

@ -373,6 +373,53 @@ extension MessageCell {
}
}
// MARK: - Convenience Initialization
public extension MessageCell.ViewModel {
// Note: This init method is only used system-created cells or empty states
init(isTypingIndicator: Bool = false) {
self.threadVariant = .contact
self.threadIsTrusted = false
self.threadHasDisappearingMessagesEnabled = false
// Interaction Info
self.rowId = -1
self.id = -1
self.variant = .standardOutgoing
self.timestampMs = Int64.max
self.authorId = ""
self.authorNameInternal = nil
self.body = nil
self.expiresStartedAtMs = nil
self.expiresInSeconds = nil
self.state = .sent
self.hasAtLeastOneReadReceipt = false
self.mostRecentFailureText = nil
self.isTypingIndicator = isTypingIndicator
self.isSenderOpenGroupModerator = false
self.profile = nil
self.quote = nil
self.quoteAttachment = nil
self.linkPreview = nil
self.linkPreviewAttachment = nil
// Post-Query Processing Data
self.attachments = nil
self.cellType = .typingIndicator
self.authorName = ""
self.senderName = nil
self.shouldShowProfile = false
self.dateForUI = nil
self.previousVariant = nil
self.positionInCluster = .middle
self.isOnlyMessageInCluster = true
self.isLast = true
}
}
// MARK: - ConversationVC
extension MessageCell.ViewModel {

@ -11,8 +11,6 @@ NS_ASSUME_NONNULL_BEGIN
- (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController;
- (void)popAllConversationSettingsViewsWithCompletion:(void (^_Nullable)(void))completionBlock;
@end
NS_ASSUME_NONNULL_END

@ -20,11 +20,19 @@ public class InsetLockableTableView: UITableView {
}
public var oldOffset: CGPoint = .zero
public var newOffset: CGPoint = .zero
private var afterNextLayoutCondition: ((Int, [Int]) -> Bool)?
private var afterNextLayoutCallback: (() -> ())?
private var callbackCondition: ((Int, [Int]) -> Bool)?
private var afterLayoutSubviewsCallback: (() -> ())?
public override func layoutSubviews() {
newOffset = self.contentOffset
self.newOffset = self.contentOffset
// Store the callback locally to prevent infinite loops
var callback: (() -> ())?
if self.testCallbackCondition() {
callback = self.afterLayoutSubviewsCallback
self.afterLayoutSubviewsCallback = nil
}
guard !lockContentOffset else {
self.contentOffset = CGPoint(
@ -33,33 +41,38 @@ public class InsetLockableTableView: UITableView {
)
super.layoutSubviews()
self.performNextLayoutCallbackIfPossible()
callback?()
return
}
super.layoutSubviews()
callback?()
self.performNextLayoutCallbackIfPossible()
self.oldOffset = self.contentOffset
}
// MARK: - Function
// MARK: - Functions
public func afterNextLayout(when condition: @escaping (Int, [Int]) -> Bool, then callback: @escaping () -> ()) {
self.afterNextLayoutCondition = condition
self.afterNextLayoutCallback = callback
public func afterNextLayoutSubviews(
when condition: @escaping (Int, [Int]) -> Bool,
then callback: @escaping () -> ()
) {
self.callbackCondition = condition
self.afterLayoutSubviewsCallback = callback
}
private func performNextLayoutCallbackIfPossible() {
private func testCallbackCondition() -> Bool {
guard self.callbackCondition != nil else { return false }
let numSections: Int = self.numberOfSections
let numRowInSections: [Int] = (0..<numSections)
.map { self.numberOfRows(inSection: $0) }
guard self.afterNextLayoutCondition?(numSections, numRowInSections) == true else { return }
// Store the layout info locally so if they pass we can clear the states before running to
// prevent layouts within the callbacks from triggering infinite loops
guard self.callbackCondition?(numSections, numRowInSections) == true else { return false }
self.afterNextLayoutCallback?()
self.afterNextLayoutCondition = nil
self.afterNextLayoutCallback = nil
self.callbackCondition = nil
return true
}
}

@ -127,8 +127,8 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
}
private func updateSearchResults(searchText rawSearchText: String) {
let searchText = rawSearchText.stripped
guard searchText.count > 0 else {
searchResultSet = defaultSearchResults
lastSearchText = nil

@ -1,32 +0,0 @@
// 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
}
}

@ -375,7 +375,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
case .threads:
let threadId: String = section.elements[indexPath.row].threadId
show(threadId, with: .none, highlightedInteractionId: nil, animated: true)
show(threadId, with: .none, focusedInteractionId: nil, animated: true)
}
}
@ -505,10 +505,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
func show(
_ threadId: String,
with action: ConversationViewModel.Action,
highlightedInteractionId: Int64?,
focusedInteractionId: Int64?,
animated: Bool
) {
guard let conversationVC: ConversationVC = ConversationVC(threadId: threadId) else {
guard let conversationVC: ConversationVC = ConversationVC(threadId: threadId, focusedInteractionId: focusedInteractionId) else {
return
}

@ -163,9 +163,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
// Start observing for data changes
dataChangeObservable = GRDBStorage.shared.start(
viewModel.observableViewData,
onError: { error in
print("Update error \(error)!!!!")
},
onError: { _ in },
onChange: { [weak self] viewData in
// The defaul scheduler emits changes on the main thread
self?.handleUpdates(viewData)

@ -16,7 +16,6 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
static let interItemSpacing: CGFloat = 2
static let footerBarHeight: CGFloat = 40
static let loadMoreHeaderHeight: CGFloat = 100
static let autoLoadNextPageDelay: DispatchTimeInterval = .milliseconds(400)
private let viewModel: MediaGalleryViewModel
private var hasLoadedInitialData: Bool = false
@ -217,7 +216,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
self.isAutoLoadingNextPage = true
DispatchQueue.main.asyncAfter(deadline: .now() + MediaTileViewController.autoLoadNextPageDelay) { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
self?.isAutoLoadingNextPage = false
// Note: We sort the headers as we want to prioritise loading newer pages over older ones

@ -45,7 +45,7 @@ public struct SessionApp {
homeViewController.wrappedValue?.show(
threadId,
with: action,
highlightedInteractionId: focusInteractionId,
focusedInteractionId: focusInteractionId,
animated: animated
)
}

@ -9,8 +9,6 @@
// Separate iOS Frameworks from other imports.
#import "AvatarViewHelper.h"
#import "AVAudioSession+OWS.h"
#import "ConversationViewItem.h"
#import "ConversationViewModel.h"
#import "DateUtil.h"
#import "NotificationSettingsViewController.h"
#import "OWSAnyTouchGestureRecognizer.h"

@ -501,6 +501,29 @@ public extension Interaction {
}
}
// MARK: - Search Queries
public extension Interaction {
static func idsForTermWithin(threadId: String, pattern: FTS5Pattern) -> SQLRequest<Int64> {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName)
let request: SQLRequest<Int64> = """
SELECT \(interaction[.id])
FROM \(Interaction.self)
JOIN \(interactionFullTextSearch) ON (
\(interactionFullTextSearch).rowid = \(interaction.alias[Column.rowID]) AND
\(interactionFullTextSearch).\(SQL(stringLiteral: Interaction.Columns.body.name)) MATCH \(pattern)
)
WHERE \(SQL("\(interaction[.threadId]) = \(threadId)"))
ORDER BY \(interaction[.timestampMs].desc)
"""
return request
}
}
// MARK: - Convenience
public extension Interaction {

@ -14,7 +14,7 @@ public struct ThreadTypingIndicator: Codable, FetchableRecord, PersistableRecord
private static let thread = belongsTo(SessionThread.self, using: threadForeignKey)
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
case threadId
case timestampMs
}

@ -198,7 +198,7 @@ extension ConversationCell {
// MARK: - Convenience Initialization
public extension ConversationCell.ViewModel {
// Note: This init method is only used for the message requests cell or empty states
// Note: This init method is only used system-created cells or empty states
init(unreadCount: UInt = 0) {
self.threadId = "INVALID_THREAD_ID"
self.threadVariant = .contact

@ -1,255 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
// Create a searchable index for objects of type T
public class SearchIndexer<T> {
private let indexBlock: (T, YapDatabaseReadTransaction) -> String
public init(indexBlock: @escaping (T, YapDatabaseReadTransaction) -> String) {
self.indexBlock = indexBlock
}
public func index(_ item: T, transaction: YapDatabaseReadTransaction) -> String {
return normalize(indexingText: indexBlock(item, transaction))
}
private func normalize(indexingText: String) -> String {
return FullTextSearchFinder.normalize(text: indexingText)
}
}
@objc
public class FullTextSearchFinder: NSObject {
// MARK: - Dependencies
private static var tsAccountManager: TSAccountManager {
return TSAccountManager.sharedInstance()
}
// MARK: - Querying
// We want to match by prefix for "search as you type" functionality.
// SQLite does not support suffix or contains matches.
public class func query(searchText: String) -> String {
// 1. Normalize the search text.
//
// TODO: We could arguably convert to lowercase since the search
// is case-insensitive.
let normalizedSearchText = FullTextSearchFinder.normalize(text: searchText)
// 2. Split the non-numeric text into query terms (or tokens).
let nonNumericText = String(String.UnicodeScalarView(normalizedSearchText.unicodeScalars.lazy.map {
if CharacterSet.decimalDigits.contains($0) {
return " "
} else {
return $0
}
}))
var queryTerms = nonNumericText.split(separator: " ")
// 3. Add an additional numeric-only query term.
let digitsOnlyScalars = normalizedSearchText.unicodeScalars.lazy.filter {
CharacterSet.decimalDigits.contains($0)
}
let digitsOnly: Substring = Substring(String(String.UnicodeScalarView(digitsOnlyScalars)))
queryTerms.append(digitsOnly)
// 4. De-duplicate and sort query terms.
// Duplicate terms are redundant.
// Sorting terms makes the output of this method deterministic and easier to test,
// and the order won't affect the search results.
queryTerms = Array(Set(queryTerms)).sorted()
// 5. Filter the query terms.
let filteredQueryTerms = queryTerms.filter {
// Ignore empty terms.
$0.count > 0
}.map {
// Allow partial match of each term.
//
// Note that we use double-quotes to enclose each search term.
// Quoted search terms can include a few more characters than
// "bareword" (non-quoted) search terms. This shouldn't matter,
// since we're filtering all of the affected characters, but
// quoting protects us from any bugs in that logic.
"\"\($0)\"*"
}
// 6. Join terms into query string.
let query = filteredQueryTerms.joined(separator: " ")
return query
}
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 = 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<ObjCBool>) in
guard searchResultCount < maxSearchResults else {
stop.pointee = true
return
}
searchResultCount += 1
block(object, snippet)
}
}
// MARK: - Normalization
fileprivate static var charactersToRemove: CharacterSet = {
// * We want to strip punctuation - and our definition of "punctuation"
// is broader than `CharacterSet.punctuationCharacters`.
// * FTS should be robust to (i.e. ignore) illegal and control characters,
// but it's safer if we filter them ourselves as well.
var charactersToFilter = CharacterSet.punctuationCharacters
charactersToFilter.formUnion(CharacterSet.illegalCharacters)
charactersToFilter.formUnion(CharacterSet.controlCharacters)
// We want to strip all ASCII characters except:
// * Letters a-z, A-Z
// * Numerals 0-9
// * Whitespace
var asciiToFilter = CharacterSet(charactersIn: UnicodeScalar(0x0)!..<UnicodeScalar(0x80)!)
assert(!asciiToFilter.contains(UnicodeScalar(0x80)!))
asciiToFilter.subtract(CharacterSet.alphanumerics)
asciiToFilter.subtract(CharacterSet.whitespacesAndNewlines)
charactersToFilter.formUnion(asciiToFilter)
return charactersToFilter
}()
// This is a hot method, especially while running large migrations.
// Changes to it should go through a profiler to make sure large migrations
// aren't adversely affected.
@objc
public class func normalize(text: String) -> String {
// 1. Filter out invalid characters.
let filtered = text.removeCharacters(characterSet: charactersToRemove)
// 2. Simplify whitespace.
let simplified = filtered.replaceCharacters(characterSet: .whitespacesAndNewlines,
replacement: " ")
// 3. Strip leading & trailing whitespace last, since we may replace
// filtered characters with whitespace.
return simplified.trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - Index Building
private static let groupThreadIndexer: SearchIndexer<TSGroupThread> = SearchIndexer { (groupThread: TSGroupThread, transaction: YapDatabaseReadTransaction) in
let groupName = groupThread.groupModel.groupName ?? ""
let memberStrings = groupThread.groupModel.groupMemberIds.map { recipientId in
recipientIndexer.index(recipientId, transaction: transaction)
}.joined(separator: " ")
return "\(groupName) \(memberStrings)"
}
private static let contactThreadIndexer: SearchIndexer<TSContactThread> = SearchIndexer { (contactThread: TSContactThread, transaction: YapDatabaseReadTransaction) in
let recipientId = contactThread.contactSessionID()
var result = recipientIndexer.index(recipientId, transaction: transaction)
if IsNoteToSelfEnabled(),
let localNumber = tsAccountManager.storedOrCachedLocalNumber(transaction),
localNumber == recipientId {
let noteToSelfLabel = NSLocalizedString("NOTE_TO_SELF", comment: "Label for 1:1 conversation with yourself.")
result += " \(noteToSelfLabel)"
}
return result
}
private static let recipientIndexer: SearchIndexer<String> = SearchIndexer { (recipientId: String, transaction: YapDatabaseReadTransaction) in
let profile: Profile? = GRDBStorage.shared.read { db in try Profile.fetchOne(db, id: recipientId) }
return [
recipientId,
profile?.name,
profile?.nickname
]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: " ")
}
private static let messageIndexer: SearchIndexer<TSMessage> = SearchIndexer { (message: TSMessage, transaction: YapDatabaseReadTransaction) in
if let bodyText = message.bodyText(with: transaction) {
return bodyText
}
return ""
}
private class func indexContent(object: Any, transaction: YapDatabaseReadTransaction) -> String? {
if let groupThread = object as? TSGroupThread {
return self.groupThreadIndexer.index(groupThread, transaction: transaction)
} else if let contactThread = object as? TSContactThread {
guard contactThread.shouldBeVisible else {
// If we've never sent/received a message in a TSContactThread,
// then we want it to appear in the "Other Contacts" section rather
// than in the "Conversations" section.
return nil
}
return self.contactThreadIndexer.index(contactThread, transaction: transaction)
} else if let message = object as? TSMessage {
return self.messageIndexer.index(message, transaction: transaction)
} else {
return nil
}
}
// MARK: - Extension Registration
private static let dbExtensionName: String = "FullTextSearchFinderExtension"
private func ext(transaction: YapDatabaseReadTransaction) -> YapDatabaseFullTextSearchTransaction? {
return transaction.ext(FullTextSearchFinder.dbExtensionName) as? YapDatabaseFullTextSearchTransaction
}
@objc
public class func asyncRegisterDatabaseExtension(storage: OWSStorage) {
storage.asyncRegister(dbExtensionConfig, withName: dbExtensionName)
}
// Only for testing.
public class func ensureDatabaseExtensionRegistered(storage: OWSStorage) {
guard storage.registeredExtension(dbExtensionName) == nil else {
return
}
storage.register(dbExtensionConfig, withName: dbExtensionName)
}
private class var dbExtensionConfig: YapDatabaseFullTextSearch {
let contentColumnName = "content"
let handler = YapDatabaseFullTextSearchHandler.withObjectBlock { (transaction: YapDatabaseReadTransaction, dict: NSMutableDictionary, _: String, _: String, object: Any) in
dict[contentColumnName] = indexContent(object: object, transaction: transaction)
}
// update search index on contact name changes?
return YapDatabaseFullTextSearch(columnNames: ["content"],
options: nil,
handler: handler,
ftsVersion: YapDatabaseFullTextSearchFTS5Version,
versionTag: "2")
}
}

@ -733,6 +733,8 @@ public struct DataCache<T: FetchableRecordWithRowId & Identifiable> {
// MARK: - PagedData
public enum PagedData {
public static let autoLoadNextPageDelay: DispatchTimeInterval = .milliseconds(400)
// MARK: - PageInfo
public struct PageInfo {

@ -1,400 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import SessionMessagingKit
public typealias MessageSortKey = UInt64
public struct ConversationSortKey: Comparable {
let creationDate: Date
let lastMessageReceivedAtDate: Date?
// MARK: Comparable
public static func < (lhs: ConversationSortKey, rhs: ConversationSortKey) -> Bool {
let lhsDate = lhs.lastMessageReceivedAtDate ?? lhs.creationDate
let rhsDate = rhs.lastMessageReceivedAtDate ?? rhs.creationDate
return lhsDate < rhsDate
}
}
public class ConversationSearchResult<SortKey>: Comparable where SortKey: Comparable {
public let thread: ThreadViewModel
public let message: TSMessage?
public let snippet: String?
private let sortKey: SortKey
init(thread: ThreadViewModel, sortKey: SortKey, message: TSMessage? = nil, snippet: String? = nil) {
self.thread = thread
self.sortKey = sortKey
self.message = message
self.snippet = snippet
}
// MARK: Comparable
public static func < (lhs: ConversationSearchResult, rhs: ConversationSearchResult) -> Bool {
return lhs.sortKey < rhs.sortKey
}
// MARK: Equatable
public static func == (lhs: ConversationSearchResult, rhs: ConversationSearchResult) -> Bool {
return lhs.thread.thread == rhs.thread.thread &&
lhs.message?.uniqueId == rhs.message?.uniqueId
}
}
public class HomeScreenSearchResultSet: NSObject {
public let searchText: String
public let conversations: [ConversationSearchResult<ConversationSortKey>]
public let messages: [ConversationSearchResult<MessageSortKey>]
public init(searchText: String, conversations: [ConversationSearchResult<ConversationSortKey>], messages: [ConversationSearchResult<MessageSortKey>]) {
self.searchText = searchText
self.conversations = conversations
self.messages = messages
}
public class var empty: HomeScreenSearchResultSet {
return HomeScreenSearchResultSet(searchText: "", conversations: [], messages: [])
}
public class var noteToSelfOnly: HomeScreenSearchResultSet {
var conversations: [ConversationSearchResult<ConversationSortKey>] = []
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
}
}
@objc
public class GroupSearchResult: NSObject, Comparable {
public let thread: ThreadViewModel
private let sortKey: ConversationSortKey
init(thread: ThreadViewModel, sortKey: ConversationSortKey) {
self.thread = thread
self.sortKey = sortKey
}
// MARK: Comparable
public static func < (lhs: GroupSearchResult, rhs: GroupSearchResult) -> Bool {
return lhs.sortKey < rhs.sortKey
}
// MARK: Equatable
public static func == (lhs: GroupSearchResult, rhs: GroupSearchResult) -> Bool {
return lhs.thread.thread == rhs.thread.thread
}
}
@objc
public class ComposeScreenSearchResultSet: NSObject {
@objc
public let searchText: String
@objc
public let groups: [GroupSearchResult]
@objc
public var groupThreads: [TSGroupThread] {
return groups.compactMap { $0.thread.threadRecord as? TSGroupThread }
}
public init(searchText: String, groups: [GroupSearchResult]) {
self.searchText = searchText
self.groups = groups
}
@objc
public static let empty = ComposeScreenSearchResultSet(searchText: "", groups: [])
@objc
public var isEmpty: Bool {
return groups.isEmpty
}
}
@objc
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
private var tsAccountManager: TSAccountManager {
return TSAccountManager.sharedInstance()
}
// MARK: -
private let finder: FullTextSearchFinder
@objc
public static let shared: FullTextSearcher = FullTextSearcher()
override private init() {
finder = FullTextSearchFinder()
super.init()
}
@objc
public func searchForComposeScreen(searchText: String,
transaction: YapDatabaseReadTransaction) -> ComposeScreenSearchResultSet {
var groups: [GroupSearchResult] = []
self.finder.enumerateObjects(searchText: searchText, transaction: transaction) { (match: Any, snippet: String?) in
switch match {
case let groupThread as TSGroupThread:
let sortKey = ConversationSortKey(creationDate: groupThread.creationDate,
lastMessageReceivedAtDate: groupThread.lastInteractionForInbox(transaction: transaction)?.receivedAtDate())
let threadViewModel = ThreadViewModel(thread: groupThread, transaction: transaction)
let searchResult = GroupSearchResult(thread: threadViewModel, sortKey: sortKey)
groups.append(searchResult)
case is TSContactThread:
// not included in compose screen results
break
case is TSMessage:
// not included in compose screen results
break
default:
owsFailDebug("unhandled item: \(match)")
}
}
// Order the conversation and message results in reverse chronological order.
// The contact results are pre-sorted by display name.
groups.sort(by: >)
return ComposeScreenSearchResultSet(searchText: searchText, groups: groups)
}
public func searchForHomeScreen(searchText: String,
maxSearchResults: Int? = nil,
transaction: YapDatabaseReadTransaction) -> HomeScreenSearchResultSet {
var conversations: [ConversationSearchResult<ConversationSortKey>] = []
var messages: [ConversationSearchResult<MessageSortKey>] = []
var existingConversationRecipientIds: Set<String> = Set()
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)
let sortKey = ConversationSortKey(creationDate: thread.creationDate,
lastMessageReceivedAtDate: thread.lastInteractionForInbox(transaction: transaction)?.receivedAtDate())
let searchResult = ConversationSearchResult(thread: threadViewModel, sortKey: sortKey)
if let contactThread = thread as? TSContactThread {
let recipientId = contactThread.contactSessionID()
existingConversationRecipientIds.insert(recipientId)
}
conversations.append(searchResult)
} else if let message = match as? TSMessage {
let thread = message.thread(with: transaction)
let threadViewModel = ThreadViewModel(thread: thread, transaction: transaction)
let sortKey = message.sortId
let searchResult = ConversationSearchResult(thread: threadViewModel,
sortKey: sortKey,
message: message,
snippet: snippet)
messages.append(searchResult)
} else {
owsFailDebug("unhandled item: \(match)")
}
}
// Order the conversation and message results in reverse chronological order.
// The contact results are pre-sorted by display name.
conversations.sort(by: >)
messages.sort(by: >)
return HomeScreenSearchResultSet(searchText: searchText, conversations: conversations, 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] {
let threads = threads.filter { $0.name() != "Session Updates" && $0.name() != "Loki News" }
guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else {
return threads
}
return threads.filter { thread in
switch thread {
case let groupThread as TSGroupThread:
return self.groupThreadSearcher.matches(item: groupThread, query: searchText)
case let contactThread as TSContactThread:
return self.contactThreadSearcher.matches(item: contactThread, query: searchText)
default:
owsFailDebug("Unexpected thread type: \(thread)")
return false
}
}
}
@objc(filterGroupThreads:withSearchText:)
public func filterGroupThreads(_ groupThreads: [TSGroupThread], searchText: String) -> [TSGroupThread] {
guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else {
return groupThreads
}
return groupThreads.filter { groupThread in
return self.groupThreadSearcher.matches(item: groupThread, query: searchText)
}
}
@objc(filterSignalAccounts:withSearchText:)
public func filterSignalAccounts(_ signalAccounts: [SignalAccount], searchText: String) -> [SignalAccount] {
guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else {
return signalAccounts
}
return signalAccounts.filter { signalAccount in
self.signalAccountSearcher.matches(item: signalAccount, query: searchText)
}
}
// MARK: Searchers
private lazy var groupThreadSearcher: Searcher<TSGroupThread> = Searcher { (groupThread: TSGroupThread) in
let groupName = groupThread.groupModel.groupName
let memberStrings = groupThread.groupModel.groupMemberIds.map { recipientId in
self.indexingString(recipientId: recipientId)
}.joined(separator: " ")
return "\(memberStrings) \(groupName ?? "")"
}
private lazy var contactThreadSearcher: Searcher<TSContactThread> = Searcher { (contactThread: TSContactThread) in
let recipientId = contactThread.contactSessionID()
return self.conversationIndexingString(recipientId: recipientId)
}
private lazy var signalAccountSearcher: Searcher<SignalAccount> = Searcher { (signalAccount: SignalAccount) in
let recipientId = signalAccount.recipientId
return self.conversationIndexingString(recipientId: recipientId)
}
private func conversationIndexingString(recipientId: String) -> String {
var result = self.indexingString(recipientId: recipientId)
if IsNoteToSelfEnabled(),
let localNumber = tsAccountManager.localNumber(),
localNumber == recipientId {
let noteToSelfLabel = NSLocalizedString("NOTE_TO_SELF", comment: "Label for 1:1 conversation with yourself.")
result += " \(noteToSelfLabel)"
}
return result
}
private func indexingString(recipientId: String) -> String {
return "\(recipientId) \(Profile.fetchOrCreate(id: recipientId).name)"
}
}
Loading…
Cancel
Save