Introduce conversation view mapping; rework conversation view scrolling.

This commit is contained in:
Matthew Chen 2019-01-03 14:39:32 -05:00
parent 371a6a6f15
commit c775dbcd66
5 changed files with 624 additions and 362 deletions

View File

@ -165,6 +165,7 @@
34A8B3512190A40E00218A25 /* MediaAlbumCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A8B3502190A40E00218A25 /* MediaAlbumCellView.swift */; };
34ABB2C42090C59700C727A6 /* OWSResaveCollectionDBMigration.m in Sources */ = {isa = PBXBuildFile; fileRef = 34ABB2C22090C59600C727A6 /* OWSResaveCollectionDBMigration.m */; };
34ABB2C52090C59700C727A6 /* OWSResaveCollectionDBMigration.h in Headers */ = {isa = PBXBuildFile; fileRef = 34ABB2C32090C59700C727A6 /* OWSResaveCollectionDBMigration.h */; };
34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */; };
34AC09DD211B39B100997B47 /* ViewControllerUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = 34AC09BF211B39AE00997B47 /* ViewControllerUtils.h */; settings = {ATTRIBUTES = (Public, ); }; };
34AC09DE211B39B100997B47 /* OWSNavigationController.h in Headers */ = {isa = PBXBuildFile; fileRef = 34AC09C0211B39AE00997B47 /* OWSNavigationController.h */; settings = {ATTRIBUTES = (Public, ); }; };
34AC09DF211B39B100997B47 /* OWSNavigationController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34AC09C1211B39AF00997B47 /* OWSNavigationController.m */; };
@ -832,6 +833,7 @@
34A8B3502190A40E00218A25 /* MediaAlbumCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaAlbumCellView.swift; sourceTree = "<group>"; };
34ABB2C22090C59600C727A6 /* OWSResaveCollectionDBMigration.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSResaveCollectionDBMigration.m; sourceTree = "<group>"; };
34ABB2C32090C59700C727A6 /* OWSResaveCollectionDBMigration.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSResaveCollectionDBMigration.h; sourceTree = "<group>"; };
34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationMessageMapping.swift; sourceTree = "<group>"; };
34AC09BF211B39AE00997B47 /* ViewControllerUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ViewControllerUtils.h; sourceTree = "<group>"; };
34AC09C0211B39AE00997B47 /* OWSNavigationController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSNavigationController.h; sourceTree = "<group>"; };
34AC09C1211B39AF00997B47 /* OWSNavigationController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSNavigationController.m; sourceTree = "<group>"; };
@ -1613,6 +1615,7 @@
34D1F0681F8678AA0066283D /* ConversationInputTextView.m */,
34D1F0691F8678AA0066283D /* ConversationInputToolbar.h */,
34D1F06A1F8678AA0066283D /* ConversationInputToolbar.m */,
34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */,
343A65971FC4CFE7000477A1 /* ConversationScrollButton.h */,
343A65961FC4CFE6000477A1 /* ConversationScrollButton.m */,
34D1F06D1F8678AA0066283D /* ConversationViewController.h */,
@ -3529,6 +3532,7 @@
34D8C0281ED3673300188D7C /* DebugUITableViewController.m in Sources */,
45F32C222057297A00A300D5 /* MediaDetailViewController.m in Sources */,
34B3F8851E8DF1700035BE1A /* NewGroupViewController.m in Sources */,
34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.swift in Sources */,
34D8C0271ED3673300188D7C /* DebugUIMessages.m in Sources */,
34DBF003206BD5A500025978 /* OWSMessageTextView.m in Sources */,
34D1F0B41F86D31D0066283D /* ConversationCollectionView.m in Sources */,

View File

@ -0,0 +1,324 @@
//
// 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 currently loaded items
private var itemIds = [ItemId]()
// We could use this to detect synchronization errors.
private var snapshotOfLastUpdate: UInt64?
// When we enter a conversation, we want to load up to N interactions. This
// in 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.
//
// 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 zero,
// e.g. we haven't "passed" the pivot yet.
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
self.snapshotOfLastUpdate = transaction.connection.snapshot
// 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.
//
// NOTE: if we do end up "moving" the pivot, we also need to increment
// `self.desiredLength` by `afterPivotCount`.
if self.pivotSortId == nil {
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.
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
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 {
owsFailDebug("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
}
}

View File

@ -196,6 +196,7 @@ typedef enum : NSUInteger {
@property (nonatomic, nullable) NSDate *lastReloadDate;
@property (nonatomic) CGFloat scrollDistanceToBottomSnapshot;
@property (nonatomic, nullable) NSNumber *lastKnownDistanceFromBottom;
@end
@ -2954,36 +2955,6 @@ typedef enum : NSUInteger {
return OWSPrimaryStorage.sharedManager.dbReadWriteConnection;
}
- (BOOL)isScrolledToBottom
{
CGFloat contentHeight = self.safeContentHeight;
// This is a bit subtle.
//
// The _wrong_ way to determine if we're scrolled to the bottom is to
// measure whether the collection view's content is "near" the bottom edge
// of the collection view. This is wrong because the collection view
// might not have enough content to fill the collection view's bounds
// _under certain conditions_ (e.g. with the keyboard dismissed).
//
// What we're really interested in is something a bit more subtle:
// "Is the scroll view scrolled down as far as it can, "at rest".
//
// To determine that, we find the appropriate "content offset y" if
// the scroll view were scrolled down as far as possible. IFF the
// actual "content offset y" is "near" that value, we return YES.
const CGFloat kIsAtBottomTolerancePts = 5;
// Note the usage of MAX() to handle the case where there isn't enough
// content to fill the collection view at its current size.
CGFloat contentOffsetYBottom
= MAX(0.f, contentHeight + self.collectionView.contentInset.bottom - self.collectionView.bounds.size.height);
CGFloat distanceFromBottom = contentOffsetYBottom - self.collectionView.contentOffset.y;
BOOL isScrolledToBottom = distanceFromBottom <= kIsAtBottomTolerancePts;
return isScrolledToBottom;
}
#pragma mark - Audio
- (void)requestRecordingVoiceMemo
@ -3774,6 +3745,13 @@ typedef enum : NSUInteger {
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
// Constantly update the lastKnownDistanceFromBottom,
// unless we're presenting the menu actions which
// temporarily gmeddles with the content insets.
if (!OWSWindowManager.sharedManager.isPresentingMenuActions) {
self.lastKnownDistanceFromBottom = @(self.safeDistanceFromBottom);
}
[self updateLastVisibleSortId];
__weak ConversationViewController *weakSelf = self;
@ -4270,6 +4248,66 @@ typedef enum : NSUInteger {
conversationViewCell.isCellVisible = NO;
}
// We use this hook to ensure scroll state continuity. As the collection
// view's content size changes, we want to keep the same cells in view.
- (CGPoint)collectionView:(UICollectionView *)collectionView
targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset
{
if (self.lastKnownDistanceFromBottom) {
// Adjust the content offset to reflect the "last known" distance
// from the bottom of the content.
CGFloat contentOffsetYBottom = self.maxContentOffsetY;
CGFloat contentOffsetY = contentOffsetYBottom - self.lastKnownDistanceFromBottom.floatValue;
proposedContentOffset.y = contentOffsetY;
}
return proposedContentOffset;
}
#pragma mark - Scroll State
- (BOOL)isScrolledToBottom
{
CGFloat distanceFromBottom = self.safeDistanceFromBottom;
const CGFloat kIsAtBottomTolerancePts = 5;
BOOL isScrolledToBottom = distanceFromBottom <= kIsAtBottomTolerancePts;
return isScrolledToBottom;
}
- (CGFloat)safeDistanceFromBottom
{
// This is a bit subtle.
//
// The _wrong_ way to determine if we're scrolled to the bottom is to
// measure whether the collection view's content is "near" the bottom edge
// of the collection view. This is wrong because the collection view
// might not have enough content to fill the collection view's bounds
// _under certain conditions_ (e.g. with the keyboard dismissed).
//
// What we're really interested in is something a bit more subtle:
// "Is the scroll view scrolled down as far as it can, "at rest".
//
// To determine that, we find the appropriate "content offset y" if
// the scroll view were scrolled down as far as possible. IFF the
// actual "content offset y" is "near" that value, we return YES.
CGFloat contentOffsetYBottom = self.maxContentOffsetY;
CGFloat distanceFromBottom = contentOffsetYBottom - self.collectionView.contentOffset.y;
return distanceFromBottom;
}
- (CGFloat)maxContentOffsetY
{
CGFloat contentHeight = self.safeContentHeight;
// Note the usage of MAX() to handle the case where there isn't enough
// content to fill the collection view at its current size.
CGFloat contentOffsetYBottom
= MAX(0.f, contentHeight + self.collectionView.contentInset.bottom - self.collectionView.bounds.size.height);
// OWSLogVerbose(@"self.collectionView.contentInset: %@",
// NSStringFromUIEdgeInsets(self.collectionView.contentInset));
return contentOffsetYBottom;
}
#pragma mark - ContactsPickerDelegate
- (void)contactsPickerDidCancel:(ContactsPicker *)contactsPicker
@ -4430,6 +4468,8 @@ typedef enum : NSUInteger {
OWSLogVerbose(@"");
// TODO:
//
// HACK to work around radar #28167779
// "UICollectionView performBatchUpdates can trigger a crash if the collection view is flagged for layout"
// more: https://github.com/PSPDFKit-labs/radar.apple.com/tree/master/28167779%20-%20CollectionViewBatchingIssue
@ -4471,23 +4511,15 @@ typedef enum : NSUInteger {
} else if (conversationUpdate.conversationUpdateType == ConversationUpdateType_Reload) {
[self resetContentAndLayout];
[self updateLastVisibleSortId];
[self scrollToBottomAnimated:NO];
return;
}
OWSAssertDebug(conversationUpdate.conversationUpdateType == ConversationUpdateType_Diff);
OWSAssertDebug(conversationUpdate.updateItems);
BOOL wasAtBottom = [self isScrolledToBottom];
// We want sending messages to feel snappy. So, if the only
// update is a new outgoing message AND we're already scrolled to
// the bottom of the conversation, skip the scroll animation.
__block BOOL shouldAnimateScrollToBottom = !wasAtBottom;
// We want to scroll to the bottom if the user:
//
// a) already was at the bottom of the conversation.
// b) is inserting new interactions.
__block BOOL scrollToBottom = wasAtBottom;
// We want to auto-scroll to the bottom of the conversation
// if the user is inserting new interactions.
__block BOOL scrollToBottom = NO;
void (^batchUpdates)(void) = ^{
OWSAssertIsOnMainThread();
@ -4518,7 +4550,6 @@ typedef enum : NSUInteger {
TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction;
if (!outgoingMessage.isFromLinkedDevice) {
scrollToBottom = YES;
shouldAnimateScrollToBottom = NO;
}
}
@ -4545,8 +4576,8 @@ typedef enum : NSUInteger {
[self updateLastVisibleSortId];
if (scrollToBottom && shouldAnimateUpdates) {
[self scrollToBottomAnimated:shouldAnimateScrollToBottom];
if (scrollToBottom) {
[self scrollToBottomAnimated:NO];
}
};
@ -4659,6 +4690,14 @@ typedef enum : NSUInteger {
[self updateShowLoadMoreHeader];
}
- (void)conversationViewModelDidReset
{
OWSAssertIsOnMainThread();
// Scroll to bottom to get view back to a known good state.
[self scrollToBottomAnimated:NO];
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@ -70,6 +70,8 @@ typedef NS_ENUM(NSUInteger, ConversationUpdateItemType) {
- (void)conversationViewModelDidLoadPrevPage;
- (void)conversationViewModelRangeDidChange;
- (void)conversationViewModelDidReset;
- (BOOL)isObservingVMUpdates;
- (ConversationStyle *)conversationStyle;

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "ConversationViewModel.h"
@ -128,8 +128,6 @@ static const int kConversationInitialMaxRangeSize = 300;
// Never show more than n messages in conversation view at a time.
static const int kYapDatabaseRangeMaxLength = 25000;
static const int kYapDatabaseRangeMinLength = 0;
#pragma mark -
@interface ConversationViewModel ()
@ -141,24 +139,20 @@ static const int kYapDatabaseRangeMinLength = 0;
// The mapping must be updated in lockstep with the uiDatabaseConnection.
//
// * The first (required) step is to update uiDatabaseConnection using beginLongLivedReadTransaction.
// * The second (required) step is to update messageMappings.
// * The third (optional) step is to update the messageMappings range using
// updateMessageMappingRangeOptions.
// * The fourth (optional) step is to update the view items using reloadViewItems.
// * The second (required) step is to update messageMapping. The desired length of the mapping
// can be modified at this time.
// * The third (optional) step is to update the view items using reloadViewItems.
// * The steps must be done in strict order.
// * If we do any of the steps, we must do all of the required steps.
// * We can't use messageMappings or viewItems after the first step until we've
// * We can't use messageMapping or viewItems after the first step until we've
// done the last step; i.e.. we can't do any layout, since that uses the view
// items which haven't been updated yet.
// * If the first and/or second steps changes the set of messages
// their ordering and/or their state, we must do the third and fourth steps.
// * If we do the third step, we must call resetContentAndLayout afterward.
@property (nonatomic) YapDatabaseViewMappings *messageMappings;
// * Afterward, we must prod the view controller to update layout & view state.
@property (nonatomic) ConversationMessageMapping *messageMapping;
@property (nonatomic) NSArray<id<ConversationViewItem>> *viewItems;
@property (nonatomic) NSMutableDictionary<NSString *, id<ConversationViewItem>> *viewItemCache;
@property (nonatomic) NSUInteger lastRangeLength;
@property (nonatomic, nullable) ThreadDynamicInteractions *dynamicInteractions;
@property (nonatomic) BOOL hasClearedUnreadMessagesIndicator;
@property (nonatomic, nullable) NSDate *collapseCutoffDate;
@ -311,13 +305,13 @@ static const int kYapDatabaseRangeMinLength = 0;
// We need to update the "unread indicator" _before_ we determine the initial range
// size, since it depends on where the unread indicator is placed.
self.lastRangeLength = 0;
self.typingIndicatorsSender = [self.typingIndicators typingRecipientIdForThread:self.thread];
self.collapseCutoffDate = [NSDate new];
[self ensureDynamicInteractions];
[self.primaryStorage updateUIDatabaseConnectionToLatest];
[self createNewMessageMappings];
[self createNewMessageMapping];
if (![self reloadViewItems]) {
OWSFailDebug(@"failed to reload view items in configureForThread.");
}
@ -334,6 +328,10 @@ static const int kYapDatabaseRangeMinLength = 0;
selector:@selector(uiDatabaseDidUpdate:)
name:OWSUIDatabaseConnectionDidUpdateNotification
object:self.primaryStorage.dbNotificationObject];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationWillEnterForeground:)
name:OWSApplicationWillEnterForegroundNotification
object:nil];
}
- (void)viewDidLoad
@ -348,25 +346,11 @@ static const int kYapDatabaseRangeMinLength = 0;
- (BOOL)canLoadMoreItems
{
__block BOOL result;
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
YapDatabaseViewTransaction *messageDatabaseView = [transaction ext:TSMessageDatabaseViewExtensionName];
result = [self canLoadMoreItems:messageDatabaseView];
}];
return result;
}
- (BOOL)canLoadMoreItems:(YapDatabaseViewTransaction *)messageDatabaseView
{
OWSAssertDebug(messageDatabaseView);
if (self.lastRangeLength >= kYapDatabaseRangeMaxLength) {
if (self.messageMapping.desiredLength >= kYapDatabaseRangeMaxLength) {
return NO;
}
NSUInteger loadWindowSize = [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId];
NSUInteger totalMessageCount = [messageDatabaseView numberOfItemsInGroup:self.thread.uniqueId];
return loadWindowSize < totalMessageCount;
return self.messageMapping.canLoadMore;
}
- (void)applicationDidEnterBackground:(NSNotification *)notification
@ -400,7 +384,7 @@ static const int kYapDatabaseRangeMinLength = 0;
// Ensure view items are updated before trying to scroll to the
// unread indicator.
//
// loadNMoreMessages calls resetMappings which calls ensureDynamicInteractions,
// loadNMoreMessages calls resetMapping which calls ensureDynamicInteractions,
// which may move the unread indicator, and for scrollToUnreadIndicatorAnimated
// to work properly, the view items need to be updated to reflect that change.
[self.primaryStorage updateUIDatabaseConnectionToLatest];
@ -413,65 +397,61 @@ static const int kYapDatabaseRangeMinLength = 0;
{
[self.delegate conversationViewModelWillLoadMoreItems];
self.lastRangeLength = MIN(self.lastRangeLength + numberOfMessagesToLoad, (NSUInteger)kYapDatabaseRangeMaxLength);
[self resetMappings];
[self resetMappingWithAdditionalLength:numberOfMessagesToLoad];
[self.delegate conversationViewModelDidLoadMoreItems];
}
- (void)updateMessageMappingRangeOptions
- (NSUInteger)initialMessageMappingLength
{
NSUInteger rangeLength = 0;
NSUInteger rangeLength = kYapDatabasePageSize;
if (self.lastRangeLength == 0) {
// If this is the first time we're configuring the range length,
// try to take into account the position of the unread indicator
// and the "focus message".
OWSAssertDebug(self.dynamicInteractions);
// If this is the first time we're configuring the range length,
// try to take into account the position of the unread indicator
// and the "focus message".
OWSAssertDebug(self.dynamicInteractions);
if (self.focusMessageIdOnOpen) {
OWSAssertDebug(self.dynamicInteractions.focusMessagePosition);
if (self.dynamicInteractions.focusMessagePosition) {
OWSLogVerbose(@"ensuring load of focus message: %@", self.dynamicInteractions.focusMessagePosition);
rangeLength = MAX(rangeLength, 1 + self.dynamicInteractions.focusMessagePosition.unsignedIntegerValue);
}
}
if (self.dynamicInteractions.unreadIndicator) {
NSUInteger unreadIndicatorPosition
= (NSUInteger)self.dynamicInteractions.unreadIndicator.unreadIndicatorPosition;
// If there is an unread indicator, increase the initial load window
// to include it.
OWSAssertDebug(unreadIndicatorPosition > 0);
OWSAssertDebug(unreadIndicatorPosition <= kYapDatabaseRangeMaxLength);
// We'd like to include at least N seen messages,
// to give the user the context of where they left off the conversation.
const NSUInteger kPreferredSeenMessageCount = 1;
rangeLength = MAX(rangeLength, unreadIndicatorPosition + kPreferredSeenMessageCount);
if (self.focusMessageIdOnOpen) {
OWSAssertDebug(self.dynamicInteractions.focusMessagePosition);
if (self.dynamicInteractions.focusMessagePosition) {
OWSLogVerbose(@"ensuring load of focus message: %@", self.dynamicInteractions.focusMessagePosition);
rangeLength = MAX(rangeLength, 1 + self.dynamicInteractions.focusMessagePosition.unsignedIntegerValue);
}
}
if (self.dynamicInteractions.unreadIndicator) {
NSUInteger unreadIndicatorPosition
= (NSUInteger)self.dynamicInteractions.unreadIndicator.unreadIndicatorPosition;
// If there is an unread indicator, increase the initial load window
// to include it.
OWSAssertDebug(unreadIndicatorPosition > 0);
OWSAssertDebug(unreadIndicatorPosition <= kYapDatabaseRangeMaxLength);
// We'd like to include at least N seen messages,
// to give the user the context of where they left off the conversation.
const NSUInteger kPreferredSeenMessageCount = 1;
rangeLength = MAX(rangeLength, unreadIndicatorPosition + kPreferredSeenMessageCount);
}
return rangeLength;
}
- (void)updateMessageMappingWithAdditionalLength:(NSUInteger)additionalLength
{
// Range size should monotonically increase.
NSUInteger rangeLength = self.messageMapping.desiredLength + additionalLength;
// Always try to load at least a single page of messages.
rangeLength = MAX(rangeLength, kYapDatabasePageSize);
// Range size should monotonically increase.
rangeLength = MAX(rangeLength, self.lastRangeLength);
// Enforce max range size.
rangeLength = MIN(rangeLength, kYapDatabaseRangeMaxLength);
self.lastRangeLength = rangeLength;
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
[self.messageMapping updateWithDesiredLength:rangeLength transaction:transaction];
}];
YapDatabaseViewRangeOptions *rangeOptions =
[YapDatabaseViewRangeOptions flexibleRangeWithLength:rangeLength offset:0 from:YapDatabaseViewEnd];
rangeOptions.maxLength = MAX(rangeLength, kYapDatabaseRangeMaxLength);
rangeOptions.minLength = kYapDatabaseRangeMinLength;
[self.messageMappings setRangeOptions:rangeOptions forGroup:self.thread.uniqueId];
[self.delegate conversationViewModelRangeDidChange];
self.collapseCutoffDate = [NSDate new];
}
@ -480,7 +460,7 @@ static const int kYapDatabaseRangeMinLength = 0;
{
OWSAssertIsOnMainThread();
const int currentMaxRangeSize = (int)self.lastRangeLength;
const int currentMaxRangeSize = (int)self.messageMapping.desiredLength;
const int maxRangeSize = MAX(kConversationInitialMaxRangeSize, currentMaxRangeSize);
self.dynamicInteractions = [ThreadUtil ensureDynamicInteractionsForThread:self.thread
@ -547,13 +527,8 @@ static const int kYapDatabaseRangeMinLength = 0;
return;
}
// External database modifications can't be converted into incremental updates,
// so rebuild everything. This is expensive and usually isn't necessary, but
// there's no alternative.
//
// We don't need to do this if we're not observing db modifications since we'll
// do it when we resume.
[self resetMappings];
// External database modifications (e.g. changes from another process such as the SAE)
// are "flushed" using touchDbAsync when the app re-enters the foreground.
}
- (void)uiDatabaseWillUpdate:(NSNotification *)notification
@ -570,7 +545,7 @@ static const int kYapDatabaseRangeMinLength = 0;
OWSLogVerbose(@"");
NSArray *notifications = notification.userInfo[OWSUIDatabaseConnectionNotificationsKey];
NSArray<NSNotification *> *notifications = notification.userInfo[OWSUIDatabaseConnectionNotificationsKey];
OWSAssertDebug([notifications isKindOfClass:[NSArray class]]);
YapDatabaseAutoViewConnection *messageDatabaseView =
@ -578,54 +553,46 @@ static const int kYapDatabaseRangeMinLength = 0;
OWSAssertDebug([messageDatabaseView isKindOfClass:[YapDatabaseAutoViewConnection class]]);
if (![messageDatabaseView hasChangesForGroup:self.thread.uniqueId inNotifications:notifications]) {
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
[self.messageMappings updateWithTransaction:transaction];
[self.messageMapping updateWithTransaction:transaction];
}];
[self.delegate conversationViewModelDidUpdate:ConversationUpdate.minorUpdate];
return;
}
NSArray<YapDatabaseViewSectionChange *> *sectionChanges = nil;
NSArray<YapDatabaseViewRowChange *> *rowChanges = nil;
[messageDatabaseView getSectionChanges:&sectionChanges
rowChanges:&rowChanges
forNotifications:notifications
withMappings:self.messageMappings];
if ([sectionChanges count] == 0 && [rowChanges count] == 0) {
// YapDatabase will ignore insertions within the message mapping's
// range that are not within the current mapping's contents. We
// may need to extend the mapping's contents to reflect the current
// range.
[self updateMessageMappingRangeOptions];
return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.minorUpdate];
__block ConversationMessageMappingDiff *_Nullable diff = nil;
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
diff = [self.messageMapping updateAndCalculateDiffWithTransaction:transaction notifications:notifications];
}];
if (!diff) {
OWSFailDebug(@"Could not determine diff");
// resetMapping will call delegate.conversationViewModelDidUpdate.
[self resetMapping];
[self.delegate conversationViewModelDidReset];
return;
}
if (diff.addedItemIds.count < 1 && diff.removedItemIds.count < 1 && diff.updatedItemIds.count < 1) {
OWSFailDebug(@"Unexpectedly empty diff.");
}
NSMutableSet<NSString *> *diffAddedItemIds = [diff.addedItemIds mutableCopy];
NSMutableSet<NSString *> *diffRemovedItemIds = [diff.removedItemIds mutableCopy];
NSMutableSet<NSString *> *diffUpdatedItemIds = [diff.updatedItemIds mutableCopy];
for (TSOutgoingMessage *unsavedOutgoingMessage in self.unsavedOutgoingMessages) {
// unsavedOutgoingMessages should only exist for a short period (usually 30-50ms) before
// they are saved and moved into the `persistedViewItems`
OWSAssertDebug(unsavedOutgoingMessage.timestamp >= ([NSDate ows_millisecondTimeStamp] - 1 * kSecondInMs));
NSUInteger index = [rowChanges indexOfObjectPassingTest:^BOOL(
YapDatabaseViewRowChange *_Nonnull rowChange, NSUInteger idx, BOOL *_Nonnull stop) {
return [rowChange.collectionKey.key isEqualToString:unsavedOutgoingMessage.uniqueId];
}];
if (index != NSNotFound) {
// Replace the "Insert" RowChange to be an "Update" RowChange.
YapDatabaseViewRowChange *rowChange = rowChanges[index];
OWSAssertDebug(rowChange);
OWSLogVerbose(@"unsaved item has since been saved. collection key: %@", rowChange.collectionKey.key);
YapDatabaseViewRowChange *update =
[YapDatabaseViewRowChange updateCollectionKey:rowChange.collectionKey
inGroup:rowChange.originalGroup
atIndex:rowChange.finalIndex
withChanges:YapDatabaseViewChangedObject];
NSMutableArray<YapDatabaseViewRowChange *> *mutableRowChanges = [rowChanges mutableCopy];
mutableRowChanges[index] = update;
rowChanges = [mutableRowChanges copy];
BOOL isFound = ([diff.addedItemIds containsObject:unsavedOutgoingMessage.uniqueId] ||
[diff.removedItemIds containsObject:unsavedOutgoingMessage.uniqueId] ||
[diff.updatedItemIds containsObject:unsavedOutgoingMessage.uniqueId]);
if (isFound) {
// Convert the "insert" to an "update".
if ([diffAddedItemIds containsObject:unsavedOutgoingMessage.uniqueId]) {
OWSLogVerbose(@"Converting insert to update: %@", unsavedOutgoingMessage.uniqueId);
[diffAddedItemIds removeObject:unsavedOutgoingMessage.uniqueId];
[diffUpdatedItemIds removeObject:unsavedOutgoingMessage.uniqueId];
}
// Remove the unsavedOutgoingViewItem since it now exists as a persistedViewItem
NSMutableArray<TSOutgoingMessage *> *unsavedOutgoingMessages = [self.unsavedOutgoingMessages mutableCopy];
@ -643,58 +610,36 @@ static const int kYapDatabaseRangeMinLength = 0;
// reloadViewItems.
BOOL hasMalformedRowChange = NO;
NSMutableSet<NSString *> *updatedItemSet = [NSMutableSet new];
for (YapDatabaseViewRowChange *rowChange in rowChanges) {
switch (rowChange.type) {
case YapDatabaseViewChangeUpdate: {
YapCollectionKey *collectionKey = rowChange.collectionKey;
if (collectionKey.key) {
id<ConversationViewItem> _Nullable viewItem = self.viewItemCache[collectionKey.key];
if (viewItem) {
[self reloadInteractionForViewItem:viewItem];
[updatedItemSet addObject:viewItem.itemId];
} else {
OWSFailDebug(@"Update is missing view item");
hasMalformedRowChange = YES;
}
} else {
OWSFailDebug(@"Update is missing collection key");
hasMalformedRowChange = YES;
}
break;
}
case YapDatabaseViewChangeDelete: {
// Discard cached view items after deletes.
YapCollectionKey *collectionKey = rowChange.collectionKey;
if (collectionKey.key) {
[self.viewItemCache removeObjectForKey:collectionKey.key];
} else {
OWSFailDebug(@"Delete is missing collection key");
hasMalformedRowChange = YES;
}
break;
}
default:
break;
}
if (hasMalformedRowChange) {
break;
for (NSString *uniqueId in diffUpdatedItemIds) {
id<ConversationViewItem> _Nullable viewItem = self.viewItemCache[uniqueId];
if (viewItem) {
[self reloadInteractionForViewItem:viewItem];
[updatedItemSet addObject:viewItem.itemId];
} else {
OWSFailDebug(@"Update is missing view item");
hasMalformedRowChange = YES;
}
}
for (NSString *uniqueId in diffRemovedItemIds) {
[self.viewItemCache removeObjectForKey:uniqueId];
}
if (hasMalformedRowChange) {
// These errors seems to be very rare; they can only be reproduced
// using the more extreme actions in the debug UI.
OWSFailDebug(@"hasMalformedRowChange");
// resetMappings will call delegate.conversationViewModelDidUpdate.
[self resetMappings];
// resetMapping will call delegate.conversationViewModelDidUpdate.
[self resetMapping];
[self.delegate conversationViewModelDidReset];
return;
}
if (![self reloadViewItems]) {
// These errors are rare.
OWSFailDebug(@"could not reload view items; hard resetting message mappings.");
// resetMappings will call delegate.conversationViewModelDidUpdate.
[self resetMappings];
OWSFailDebug(@"could not reload view items; hard resetting message mapping.");
// resetMapping will call delegate.conversationViewModelDidUpdate.
[self resetMapping];
[self.delegate conversationViewModelDidReset];
return;
}
@ -718,9 +663,9 @@ static const int kYapDatabaseRangeMinLength = 0;
if (![self reloadViewItems]) {
// These errors are rare.
OWSFailDebug(@"could not reload view items; hard resetting message mappings.");
// resetMappings will call delegate.conversationViewModelDidUpdate.
[self resetMappings];
OWSFailDebug(@"could not reload view items; hard resetting message mapping.");
// resetMapping will call delegate.conversationViewModelDidUpdate.
[self resetMapping];
return;
}
@ -928,12 +873,18 @@ static const int kYapDatabaseRangeMinLength = 0;
case ConversationUpdateItemType_Insert: {
id<ConversationViewItem> viewItem = updateItem.viewItem;
OWSAssertDebug(viewItem);
if (([viewItem.interaction isKindOfClass:[TSIncomingMessage class]] ||
[viewItem.interaction isKindOfClass:[TSOutgoingMessage class]])
&& updateItem.newIndex >= oldViewItemCount) {
continue;
switch (viewItem.interaction.interactionType) {
case OWSInteractionType_IncomingMessage:
case OWSInteractionType_OutgoingMessage:
case OWSInteractionType_TypingIndicator:
if (updateItem.newIndex < oldViewItemCount) {
isOnlyModifyingLastMessage = NO;
}
break;
default:
isOnlyModifyingLastMessage = NO;
break;
}
isOnlyModifyingLastMessage = NO;
break;
}
case ConversationUpdateItemType_Update: {
@ -942,12 +893,18 @@ static const int kYapDatabaseRangeMinLength = 0;
continue;
}
OWSAssertDebug(viewItem);
if (([viewItem.interaction isKindOfClass:[TSIncomingMessage class]] ||
[viewItem.interaction isKindOfClass:[TSOutgoingMessage class]])
&& updateItem.newIndex >= oldViewItemCount) {
continue;
switch (viewItem.interaction.interactionType) {
case OWSInteractionType_IncomingMessage:
case OWSInteractionType_OutgoingMessage:
case OWSInteractionType_TypingIndicator:
if (updateItem.newIndex < updateItems.count - 1) {
isOnlyModifyingLastMessage = NO;
}
break;
default:
isOnlyModifyingLastMessage = NO;
break;
}
isOnlyModifyingLastMessage = NO;
break;
}
}
@ -956,40 +913,51 @@ static const int kYapDatabaseRangeMinLength = 0;
return shouldAnimateRowUpdates;
}
- (void)createNewMessageMappings
- (void)createNewMessageMapping
{
if (self.thread.uniqueId.length > 0) {
self.messageMappings = [[YapDatabaseViewMappings alloc] initWithGroups:@[ self.thread.uniqueId ]
view:TSMessageDatabaseViewExtensionName];
} else {
if (self.thread.uniqueId.length < 1) {
OWSFailDebug(@"uniqueId unexpectedly empty for thread: %@", self.thread);
self.messageMappings =
[[YapDatabaseViewMappings alloc] initWithGroups:@[] view:TSMessageDatabaseViewExtensionName];
}
self.messageMapping = [[ConversationMessageMapping alloc] initWithGroup:self.thread.uniqueId
desiredLength:self.initialMessageMappingLength];
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
[self.messageMappings updateWithTransaction:transaction];
[self.messageMapping updateWithTransaction:transaction];
}];
// We need to impose the range restrictions on the mappings immediately to avoid
// doing a great deal of unnecessary work and causing a perf hotspot.
[self updateMessageMappingRangeOptions];
}
- (void)resetMappings
// This is more expensive than incremental updates.
//
// It can be used to get back to a known good state, or to respon
// It can be done to get back to a known good state
- (void)resetMapping
{
OWSAssertDebug(self.messageMappings);
// Don't extend the mapping's desired length.
[self resetMappingWithAdditionalLength:0];
}
- (void)resetMappingWithAdditionalLength:(NSUInteger)additionalLength
{
OWSAssertDebug(self.messageMapping);
[self updateMessageMappingWithAdditionalLength:additionalLength];
if (self.messageMappings != nil) {
// Make sure our mapping and range state is up-to-date.
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
[self.messageMappings updateWithTransaction:transaction];
}];
[self updateMessageMappingRangeOptions];
}
self.collapseCutoffDate = [NSDate new];
[self ensureDynamicInteractions];
if (![self reloadViewItems]) {
OWSFailDebug(@"failed to reload view items in resetMapping.");
}
[self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate];
}
- (void)touchDbAsync
{
OWSLogInfo(@"");
// There appears to be a bug in YapDatabase that sometimes delays modifications
// made in another process (e.g. the SAE) from showing up in other processes.
// There's a simple workaround: a trivial write to the database flushes changes
@ -997,8 +965,11 @@ static const int kYapDatabaseRangeMinLength = 0;
[self.editingDatabaseConnection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
[transaction setObject:[NSUUID UUID].UUIDString forKey:@"conversation_view_noop_mod" inCollection:@"temp"];
}];
}
[self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate];
- (void)applicationWillEnterForeground:(NSNotification *)notification
{
[self touchDbAsync];
}
#pragma mark - View Items
@ -1198,7 +1169,7 @@ static const int kYapDatabaseRangeMinLength = 0;
NSMutableArray<id<ConversationViewItem>> *viewItems = [NSMutableArray new];
NSMutableDictionary<NSString *, id<ConversationViewItem>> *viewItemCache = [NSMutableDictionary new];
NSUInteger count = [self.messageMappings numberOfItemsInSection:0];
NSArray<NSString *> *loadedUniqueIds = [self.messageMapping loadedUniqueIds];
BOOL isGroupThread = self.thread.isGroupThread;
ConversationStyle *conversationStyle = self.delegate.conversationStyle;
@ -1216,43 +1187,43 @@ static const int kYapDatabaseRangeMinLength = 0;
transaction:transaction
conversationStyle:conversationStyle];
}
[viewItems addObject:viewItem];
OWSAssertDebug(!viewItemCache[interaction.uniqueId]);
viewItemCache[interaction.uniqueId] = viewItem;
[viewItems addObject:viewItem];
return viewItem;
};
NSMutableSet<NSString *> *interactionIds = [NSMutableSet new];
BOOL canLoadMoreItems = self.messageMapping.canLoadMore;
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
NSMutableArray<TSInteraction *> *interactions = [NSMutableArray new];
NSMutableSet<NSString *> *interactionIds = [NSMutableSet new];
YapDatabaseViewTransaction *messageDatabaseView = [transaction ext:TSMessageDatabaseViewExtensionName];
OWSAssertDebug(messageDatabaseView);
for (NSUInteger row = 0; row < count; row++) {
TSInteraction *interaction =
[messageDatabaseView objectAtRow:row inSection:0 withMappings:self.messageMappings];
YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName];
OWSAssertDebug(viewTransaction);
for (NSString *uniqueId in loadedUniqueIds) {
TSInteraction *_Nullable interaction =
[TSInteraction fetchObjectWithUniqueID:uniqueId transaction:transaction];
if (!interaction) {
OWSFailDebug(
@"missing interaction in message mappings: %lu / %lu.", (unsigned long)row, (unsigned long)count);
OWSFailDebug(@"missing interaction in message mapping: %@.", uniqueId);
// TODO: Add analytics.
hasError = YES;
continue;
}
if (!interaction.uniqueId) {
OWSFailDebug(@"invalid interaction in message mappings: %lu / %lu: %@.",
(unsigned long)row,
(unsigned long)count,
interaction);
OWSFailDebug(@"invalid interaction in message mapping: %@.", interaction);
// TODO: Add analytics.
hasError = YES;
continue;
}
[interactions addObject:interaction];
if ([interactionIds containsObject:interaction.uniqueId]) {
OWSFailDebug(@"Duplicate interaction: %@", interaction.uniqueId);
continue;
}
[interactionIds addObject:interaction.uniqueId];
}
BOOL canLoadMoreItems = [self canLoadMoreItems:messageDatabaseView];
OWSContactOffersInteraction *_Nullable offers =
[self tryToBuildContactOffersInteractionWithTransaction:transaction
loadedInteractions:interactions
@ -1285,8 +1256,13 @@ static const int kYapDatabaseRangeMinLength = 0;
if (self.unsavedOutgoingMessages.count > 0) {
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *_Nonnull transaction) {
for (TSOutgoingMessage *outgoingMessage in self.unsavedOutgoingMessages) {
for (TSOutgoingMessage *outgoingMessage in [self.unsavedOutgoingMessages copy]) {
if ([interactionIds containsObject:outgoingMessage.uniqueId]) {
OWSFailDebug(@"Duplicate interaction: %@", outgoingMessage.uniqueId);
continue;
}
tryToAddViewItem(outgoingMessage, transaction);
[interactionIds addObject:outgoingMessage.uniqueId];
}
}];
}
@ -1583,125 +1559,26 @@ static const int kYapDatabaseRangeMinLength = 0;
OWSAssertDebug(quotedReply.timestamp > 0);
OWSAssertDebug(quotedReply.authorId.length > 0);
// TODO:
// We try to find the index of the item within the current thread's
// interactions that includes the "quoted interaction".
//
// NOTE: There are two indices:
//
// * The "group index" of the member of the database views group at
// the db conneciton's current checkpoint.
// * The "index row/section" in the message mapping.
//
// NOTE: Since the range _IS NOT_ filtered by author,
// and timestamp collisions are possible, it's possible
// for:
//
// * The range to include more than the "quoted interaction".
// * The range to be non-empty but NOT include the "quoted interaction",
// although this would be a bug.
__block TSInteraction *_Nullable quotedInteraction;
__block NSUInteger threadInteractionCount = 0;
__block NSNumber *_Nullable groupIndex = nil;
if (quotedReply.isRemotelySourced) {
return nil;
}
__block NSIndexPath *_Nullable indexPath = nil;
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
quotedInteraction = [ThreadUtil findInteractionInThreadByTimestamp:quotedReply.timestamp
authorId:quotedReply.authorId
threadUniqueId:self.thread.uniqueId
transaction:transaction];
TSInteraction *_Nullable quotedInteraction =
[ThreadUtil findInteractionInThreadByTimestamp:quotedReply.timestamp
authorId:quotedReply.authorId
threadUniqueId:self.thread.uniqueId
transaction:transaction];
if (!quotedInteraction) {
return;
}
YapDatabaseAutoViewTransaction *_Nullable extension =
[transaction extension:TSMessageDatabaseViewExtensionName];
if (!extension) {
OWSFailDebug(@"Couldn't load view.");
return;
}
threadInteractionCount = [extension numberOfItemsInGroup:self.thread.uniqueId];
groupIndex = [self findGroupIndexOfThreadInteraction:quotedInteraction transaction:transaction];
indexPath = [self.messageMapping ensureLoadWindowContainsWithUniqueId:quotedInteraction.uniqueId
transaction:transaction];
}];
if (!quotedInteraction || !groupIndex) {
return nil;
}
NSUInteger indexRow = 0;
NSUInteger indexSection = 0;
BOOL isInMappings = [self.messageMappings getRow:&indexRow
section:&indexSection
forIndex:groupIndex.unsignedIntegerValue
inGroup:self.thread.uniqueId];
if (!isInMappings) {
NSInteger desiredWindowSize = MAX(0, 1 + (NSInteger)threadInteractionCount - groupIndex.integerValue);
NSUInteger oldLoadWindowSize = [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId];
NSInteger additionalItemsToLoad = MAX(0, desiredWindowSize - (NSInteger)oldLoadWindowSize);
if (additionalItemsToLoad < 1) {
OWSLogError(@"Couldn't determine how to load quoted reply.");
return nil;
}
// Try to load more messages so that the quoted message
// is in the load window.
//
// This may fail if the quoted message is very old, in which
// case we'll load the max number of messages.
[self loadNMoreMessages:(NSUInteger)additionalItemsToLoad];
// `loadNMoreMessages` will reset the mapping and possibly
// integrate new changes, so we need to reload the "group index"
// of the quoted message.
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
groupIndex = [self findGroupIndexOfThreadInteraction:quotedInteraction transaction:transaction];
}];
if (!quotedInteraction || !groupIndex) {
OWSLogError(@"Failed to find quoted reply in group.");
return nil;
}
isInMappings = [self.messageMappings getRow:&indexRow
section:&indexSection
forIndex:groupIndex.unsignedIntegerValue
inGroup:self.thread.uniqueId];
if (!isInMappings) {
OWSLogError(@"Could not load quoted reply into mapping.");
return nil;
}
}
// The mapping indices and view item indices don't always align for corrupt mappings.
__block TSInteraction *_Nullable interaction;
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
YapDatabaseViewTransaction *messageDatabaseView = [transaction ext:TSMessageDatabaseViewExtensionName];
OWSAssertDebug(messageDatabaseView);
interaction =
[messageDatabaseView objectAtRow:indexRow inSection:indexSection withMappings:self.messageMappings];
}];
if (!interaction) {
OWSFailDebug(@"Could not locate interaction for quoted reply.");
return nil;
}
id<ConversationViewItem> _Nullable viewItem = self.viewItemCache[interaction.uniqueId];
if (!viewItem) {
OWSFailDebug(@"Could not locate view item for quoted reply.");
return nil;
}
NSUInteger viewItemIndex = [self.viewItems indexOfObject:viewItem];
if (viewItemIndex == NSNotFound) {
OWSFailDebug(@"Could not locate view item index for quoted reply.");
return nil;
}
return [NSIndexPath indexPathForRow:(NSInteger)viewItemIndex inSection:0];
return indexPath;
}
- (nullable NSNumber *)findGroupIndexOfThreadInteraction:(TSInteraction *)interaction
@ -1749,7 +1626,23 @@ static const int kYapDatabaseRangeMinLength = 0;
// Update the view items if necessary.
// We don't have to do this if they haven't been configured yet.
if (didChange && self.viewItems != nil) {
[self updateForTransientItems];
// When we receive an incoming message, we clear any typing indicators
// from that sender. Ideally, we'd like both changes (disappearance of
// the typing indicators, appearance of the incoming message) to show up
// in the view at the same time, rather than as a "jerky" two-step
// visual change.
//
// Unfortunately, the view model learns of these changes by separate
// channels: the incoming message is a database modification and the
// typing indicator change arrives via this notification.
//
// Therefore we pause briefly before updating the view model to reflect
// typing indicators state changes so that the database modification
// can usually arrive first and update the view to reflect both changes.
__weak ConversationViewModel *weakSelf = self;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1f * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[weakSelf updateForTransientItems];
});
}
}