mirror of https://github.com/oxen-io/session-ios
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 glitchespull/612/head
parent
62c886e764
commit
45d0faee6a
@ -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
|
||||
}
|
||||
}
|
@ -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
@ -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
|
||||
}
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
@ -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…
Reference in New Issue