diff --git a/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentFooter.swift b/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentFooter.swift index 2a8dd85945..ebd89fa235 100644 --- a/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentFooter.swift +++ b/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentFooter.swift @@ -100,7 +100,10 @@ public class CVComponentFooter: CVComponentBase, CVComponent { let timestampLabel = componentView.timestampLabel let textColor: UIColor - if isOverlayingMedia { + if wasRemotelyDeleted { + owsAssertDebug(!isOverlayingMedia) + textColor = Theme.primaryTextColor + } else if isOverlayingMedia { textColor = .ows_white } else if isOutsideBubble { textColor = Theme.secondaryTextAndIconColor @@ -218,7 +221,7 @@ public class CVComponentFooter: CVComponentBase, CVComponent { } private var tapForMoreLabelConfig: CVLabelConfig? { - guard hasTapForMore else { + guard hasTapForMore, !wasRemotelyDeleted else { return nil } guard let message = interaction as? TSMessage else { diff --git a/Signal/src/ViewControllers/ConversationView/CVViewState.swift b/Signal/src/ViewControllers/ConversationView/CVViewState.swift index 34c15e5a41..390e624f59 100644 --- a/Signal/src/ViewControllers/ConversationView/CVViewState.swift +++ b/Signal/src/ViewControllers/ConversationView/CVViewState.swift @@ -73,6 +73,7 @@ public class CVViewState: NSObject { public var scrollContinuity: ScrollContinuity = .bottom public var scrollContinuityMap: CVScrollContinuityMap? public var scrollActionForSizeTransition: CVScrollAction? + public var scrollActionForUpdate: CVScrollAction? public var lastKnownDistanceFromBottom: CGFloat? @objc public var lastSearchedText: String? diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+CVC.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+CVC.swift index e039154d4a..bc502cac01 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+CVC.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+CVC.swift @@ -457,6 +457,7 @@ extension ConversationViewController: CVLoadCoordinatorDelegate { let renderState = update.renderState let isScrolledToBottom = updateToken.isScrolledToBottom + let viewState = self.viewState var scrollAction = scrollActionParam @@ -469,23 +470,11 @@ extension ConversationViewController: CVLoadCoordinatorDelegate { owsFailDebug("Missing loadType.") } - let batchUpdatesBlock = { - AssertIsOnMainThread() - - let section = Self.messageSection - var hasInserted = false - var hasUpdated = false + // Update scroll action to auto-scroll if necessary. + if scrollAction.action == .none, !self.isUserScrolling { for item in items { switch item { - case .insert(let renderItem, let newIndex): - // Always perform inserts before updates. - owsAssertDebug(!hasUpdated) - - Logger.verbose("insert newIndex: \(newIndex)") - - let indexPath = IndexPath(row: newIndex, section: section) - self.collectionView.insertItems(at: [indexPath]) - hasInserted = true + case .insert(let renderItem, _): var wasJustInserted = false if let lastMessageForInboxSortId = updateToken.lastMessageForInboxSortId, @@ -500,31 +489,54 @@ extension ConversationViewController: CVLoadCoordinatorDelegate { case .typingIndicator: isAutoScrollInteraction = true case .incomingMessage, - .outgoingMessage, - .call, - .error, - .info: + .outgoingMessage, + .call, + .error, + .info: isAutoScrollInteraction = wasJustInserted default: isAutoScrollInteraction = false } - if scrollAction.action == .none, - !self.isUserScrolling { - if let outgoingMessage = renderItem.interaction as? TSOutgoingMessage, - !outgoingMessage.isFromLinkedDevice, - wasJustInserted { - // Whenever we send an outgoing message from the local device, - // auto-scroll to the bottom of the conversation, regardless - // of scroll state. - scrollAction = CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false) - } else if isAutoScrollInteraction, - isScrolledToBottom { - // If we're already at the bottom of the conversation and - // a freshly inserted message or typing indicator appears, - // auto-scroll to show it. - scrollAction = CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false) - } + + if let outgoingMessage = renderItem.interaction as? TSOutgoingMessage, + !outgoingMessage.isFromLinkedDevice, + wasJustInserted { + // Whenever we send an outgoing message from the local device, + // auto-scroll to the bottom of the conversation, regardless + // of scroll state. + scrollAction = CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false) + break + } else if isAutoScrollInteraction, + isScrolledToBottom { + // If we're already at the bottom of the conversation and + // a freshly inserted message or typing indicator appears, + // auto-scroll to show it. + scrollAction = CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false) + break } + default: + break + } + } + } + + viewState.scrollActionForUpdate = scrollAction + + let batchUpdatesBlock = { + AssertIsOnMainThread() + + let section = Self.messageSection + var hasInserted = false + var hasUpdated = false + for item in items { + switch item { + case .insert(_, let newIndex): + // Always perform inserts before updates. + owsAssertDebug(!hasUpdated) + Logger.verbose("insert newIndex: \(newIndex)") + let indexPath = IndexPath(row: newIndex, section: section) + self.collectionView.insertItems(at: [indexPath]) + hasInserted = true case .update(_, let oldIndex, _): Logger.verbose("update oldIndex: \(oldIndex)") let indexPath = IndexPath(row: oldIndex, section: section) @@ -554,6 +566,8 @@ extension ConversationViewController: CVLoadCoordinatorDelegate { self.perform(scrollAction: scrollAction) + viewState.scrollActionForUpdate = nil + if !finished { Logger.warn("performBatchUpdates did not finish") Logger.warn("Layout: \(self.layout.debugDescription)") diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+LastVisibleSortId.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+LastVisibleSortId.swift index e57246ea19..9cb3a74e0e 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+LastVisibleSortId.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+LastVisibleSortId.swift @@ -24,6 +24,7 @@ extension ConversationViewController { // with the updated -contentOffset in -scrollViewDidScroll:. So instead, we'll just see what layoutAttributes // are now in the collection view's visible content rect. This should be safe, since it's computed from the // already updated -contentOffset. + layout.prepare() let visibleLayoutAttributes = layout.layoutAttributesForElements(in: visibleContentRect) ?? [] let lastVisibleIndexPath = visibleLayoutAttributes @@ -91,6 +92,8 @@ extension ConversationViewController { } private func percentOfIndexPathVisibleAboveBottom(_ indexPath: IndexPath) -> CGFloat { + layout.prepare() + // If we don't have layout attributes, it's not visible guard let attributes = layout.layoutAttributesForItem(at: indexPath) else { return 0.0 } diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Scroll.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Scroll.swift index 4717d1d838..e15133032d 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Scroll.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Scroll.swift @@ -87,7 +87,7 @@ public struct CVScrollAction: Equatable, CustomStringConvertible { // MARK: - CustomStringConvertible public var description: String { - "action: \(action), isAnimated: \(isAnimated)" + "[scrollAction: \(action), isAnimated: \(isAnimated)]" } } @@ -236,6 +236,7 @@ extension ConversationViewController { guard !isUserScrolling else { return } view.layoutIfNeeded() + layout.prepare() guard let attributes = layout.layoutAttributesForItem(at: indexPath) else { return owsFailDebug("failed to get attributes for indexPath \(indexPath)") @@ -505,23 +506,28 @@ extension ConversationViewController { Logger.verbose("---- proposedContentOffset: \(proposedContentOffset)") if isPresentingMessageActions, - let contentOffset = contentOffsetForMessageActionInteraction { - Logger.verbose("---- contentOffsetForMessageActionInteraction: \(contentOffset)") + let contentOffset = targetContentOffsetForMessageActionInteraction { + Logger.verbose("---- targetContentOffsetForMessageActionInteraction: \(contentOffset)") return contentOffset } - if let contentOffset = contentOffsetForSizeTransition() { - Logger.verbose("---- contentOffsetForSizeTransition: \(contentOffset)") + if let contentOffset = targetContentOffsetForSizeTransition() { + Logger.verbose("---- targetContentOffsetForSizeTransition: \(contentOffset)") return contentOffset } - if let contentOffset = contentOffsetForScrollContinuityMap() { - Logger.verbose("---- contentOffsetForScrollContinuityMap: \(contentOffset)") + if let contentOffset = targetContentOffsetForUpdate() { + Logger.verbose("---- targetContentOffsetForUpdate: \(contentOffset)") + return contentOffset + } + + if let contentOffset = targetContentOffsetForScrollContinuityMap() { + Logger.verbose("---- targetContentOffsetForScrollContinuityMap: \(contentOffset)") return contentOffset } if scrollContinuity == .bottom, - let contentOffset = contentOffsetForBottom() { + let contentOffset = targetContentOffsetForBottom() { Logger.verbose("---- forLastKnownDistanceFromBottom: \(contentOffset)") return contentOffset } @@ -530,7 +536,7 @@ extension ConversationViewController { return proposedContentOffset } - private func contentOffsetForBottom() -> CGPoint? { + private func targetContentOffsetForBottom() -> CGPoint? { guard let lastKnownDistanceFromBottom = self.lastKnownDistanceFromBottom else { return nil } @@ -607,7 +613,7 @@ extension ConversationViewController { // 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. - private func contentOffsetForScrollContinuityMap() -> CGPoint? { + private func targetContentOffsetForScrollContinuityMap() -> CGPoint? { guard let scrollContinuityMap = viewState.scrollContinuityMap else { return nil } @@ -652,10 +658,27 @@ extension ConversationViewController { return nil } - private func contentOffsetForSizeTransition() -> CGPoint? { + private func targetContentOffsetForSizeTransition() -> CGPoint? { guard let scrollAction = viewState.scrollActionForSizeTransition else { return nil } + owsAssertDebug(!scrollAction.isAnimated) + return targetContentOffsetForScrollAction(scrollAction) + } + + private func targetContentOffsetForUpdate() -> CGPoint? { + guard let scrollAction = viewState.scrollActionForUpdate else { + return nil + } + guard scrollAction.action != .none, !scrollAction.isAnimated else { + return nil + } + return targetContentOffsetForScrollAction(scrollAction) + } + + private func targetContentOffsetForScrollAction(_ scrollAction: CVScrollAction) -> CGPoint? { + owsAssertDebug(!scrollAction.isAnimated) + layout.prepare() switch scrollAction.action { @@ -705,7 +728,7 @@ extension ConversationViewController { } } - private var contentOffsetForMessageActionInteraction: CGPoint? { + private var targetContentOffsetForMessageActionInteraction: CGPoint? { guard isPresentingMessageActions, let messageActionsViewController = messageActionsViewController else { owsFailDebug("Not presenting message actions.") @@ -718,6 +741,7 @@ extension ConversationViewController { // This is expected if the menu action interaction is being deleted. return nil } + layout.prepare() guard let layoutAttributes = layout.layoutAttributesForItem(at: indexPath) else { owsFailDebug("Missing layoutAttributes.") return nil diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Selection.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Selection.swift index 6d3b461bf2..d2b7da5726 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Selection.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Selection.swift @@ -200,6 +200,7 @@ extension ConversationViewController { return nil } + layout.prepare() guard let firstFrame = self.layout.layoutAttributesForItem(at: first)?.frame else { owsFailDebug("firstFrame was unexpectedly nil") return nil diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 1b11b5b860..c030882ebe 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -1830,7 +1830,7 @@ typedef enum : NSUInteger { return [self indexPathForInteractionUniqueId:messageActionInteractionId] == nil; } -- (nullable NSValue *)contentOffsetForMessageActionInteraction +- (nullable NSValue *)targetContentOffsetForMessageActionInteraction { OWSAssertDebug(self.messageActionsViewController); @@ -1845,6 +1845,7 @@ typedef enum : NSUInteger { // This is expected if the menu action interaction is being deleted. return nil; } + [self.layout prepareLayout]; UICollectionViewLayoutAttributes *_Nullable layoutAttributes = [self.layout layoutAttributesForItemAtIndexPath:indexPath]; if (layoutAttributes == nil) { diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewLayout.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewLayout.swift index 0cf73f657f..c4dbcb5b70 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewLayout.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewLayout.swift @@ -208,8 +208,9 @@ public class ConversationViewLayout: UICollectionViewLayout { @objc public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { - var result = [UICollectionViewLayoutAttributes]() + owsAssertDebug(hasLayout) + var result = [UICollectionViewLayoutAttributes]() if let headerLayoutAttributes = headerLayoutAttributes { result.append(headerLayoutAttributes) } @@ -222,6 +223,8 @@ public class ConversationViewLayout: UICollectionViewLayout { @objc public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + owsAssertDebug(hasLayout) + guard let attributes = itemAttributesMap[indexPath.row] else { Logger.verbose("Missing attributes: \(itemAttributesMap.keys)") return nil @@ -231,6 +234,8 @@ public class ConversationViewLayout: UICollectionViewLayout { @objc public override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + owsAssertDebug(hasLayout) + if elementKind == UICollectionView.elementKindSectionHeader { return headerLayoutAttributes } else if elementKind == UICollectionView.elementKindSectionFooter { diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift index 80c280d2c7..e971525a30 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift @@ -1046,7 +1046,7 @@ extension ConversationSettingsViewController: ColorPickerDelegate { self.thread.updateConversationColorName(conversationColor.name, transaction: transaction) } - contactsManager.avatarCache.removeAllImages() + contactsManager.removeAllFromAvatarCache() contactsManager.clearColorNameCache() updateTableContents() conversationSettingsViewDelegate?.conversationColorWasUpdated() diff --git a/SignalMessaging/contacts/OWSContactsManager.h b/SignalMessaging/contacts/OWSContactsManager.h index 0859cf7347..e5622d6028 100644 --- a/SignalMessaging/contacts/OWSContactsManager.h +++ b/SignalMessaging/contacts/OWSContactsManager.h @@ -26,8 +26,8 @@ extern NSString *const OWSContactsManagerSignalAccountsDidChangeNotification; @property (nonatomic, readonly) SDSKeyValueStore *keyValueStore; -@property (nonnull, readonly) ImageCache *avatarCache; - +// Do not access this property directly. +@property (nonnull, readonly) ImageCache *avatarCachePrivate; @property (atomic, readonly) NSArray *allContacts; diff --git a/SignalMessaging/contacts/OWSContactsManager.m b/SignalMessaging/contacts/OWSContactsManager.m index ad02f418c0..0e776fad74 100644 --- a/SignalMessaging/contacts/OWSContactsManager.m +++ b/SignalMessaging/contacts/OWSContactsManager.m @@ -80,7 +80,7 @@ NSString *const OWSContactsManagerKeyNextFullIntersectionDate = @"OWSContactsMan _keyValueStore = [[SDSKeyValueStore alloc] initWithCollection:OWSContactsManagerCollection]; // TODO: We need to configure the limits of this cache. - _avatarCache = [ImageCache new]; + _avatarCachePrivate = [ImageCache new]; _colorNameCache = [NSCache new]; _allContacts = @[]; @@ -514,7 +514,7 @@ NSString *const OWSContactsManagerKeyNextFullIntersectionDate = @"OWSContactsMan SignalServiceAddress *address = notification.userInfo[kNSNotificationKey_ProfileAddress]; OWSAssertDebug(address.isValid); - [self.avatarCache removeAllImagesForKey:address.stringForDisplay]; + [self removeAllFromAvatarCacheWithKey:address.stringForDisplay]; }]; } @@ -553,7 +553,7 @@ NSString *const OWSContactsManagerKeyNextFullIntersectionDate = @"OWSContactsMan [self.cnContactCache removeAllObjects]; [self.cnContactAvatarCache removeAllObjects]; - [self.avatarCache removeAllImages]; + [self removeAllFromAvatarCache]; [self intersectContacts:allContacts isUserRequested:isUserRequested diff --git a/SignalMessaging/contacts/OWSContactsManager.swift b/SignalMessaging/contacts/OWSContactsManager.swift index 6036aff517..a1833a564f 100644 --- a/SignalMessaging/contacts/OWSContactsManager.swift +++ b/SignalMessaging/contacts/OWSContactsManager.swift @@ -13,6 +13,34 @@ public extension OWSContactsManager { return SDSDatabaseStorage.shared } + // MARK: - Avatar Cache + + private static let unfairLock = UnfairLock() + + func getImageFromAvatarCache(key: String, diameter: CGFloat) -> UIImage? { + Self.unfairLock.withLock { + self.avatarCachePrivate.image(forKey: key as NSString, diameter: diameter) + } + } + + func setImageForAvatarCache(_ image: UIImage, forKey key: String, diameter: CGFloat) { + Self.unfairLock.withLock { + self.avatarCachePrivate.setImage(image, forKey: key as NSString, diameter: diameter) + } + } + + func removeAllFromAvatarCacheWithKey(_ key: String) { + Self.unfairLock.withLock { + self.avatarCachePrivate.removeAllImages(forKey: key as NSString) + } + } + + func removeAllFromAvatarCache() { + Self.unfairLock.withLock { + self.avatarCachePrivate.removeAllImages() + } + } + // MARK: - func sortSignalAccountsWithSneakyTransaction(_ signalAccounts: [SignalAccount]) -> [SignalAccount] { diff --git a/SignalMessaging/utils/DisplayableText.swift b/SignalMessaging/utils/DisplayableText.swift index 6a9b2761d7..c239cc0c39 100644 --- a/SignalMessaging/utils/DisplayableText.swift +++ b/SignalMessaging/utils/DisplayableText.swift @@ -297,7 +297,9 @@ public class DisplayableText: NSObject { switch textValue { case .text(let text): - let truncatedText = text.substring(to: snippetLength) + let truncatedText = (text.substring(to: snippetLength) + .ows_stripped() + + "…") truncatedContent = Content(textValue: .text(text: truncatedText), naturalAlignment: truncatedText.naturalTextAlignment) case .attributedText(let attributedText): diff --git a/SignalMessaging/utils/OWSContactAvatarBuilder.m b/SignalMessaging/utils/OWSContactAvatarBuilder.m index a5ab927cb7..4810ae4359 100644 --- a/SignalMessaging/utils/OWSContactAvatarBuilder.m +++ b/SignalMessaging/utils/OWSContactAvatarBuilder.m @@ -155,8 +155,8 @@ NS_ASSUME_NONNULL_BEGIN OWSCAssertDebug(self.address.isLocalAddress); NSString *noteToSelfCacheKey = [NSString stringWithFormat:@"%@:note-to-self", self.cacheKey]; UIImage *_Nullable cachedAvatar = - [OWSContactAvatarBuilder.contactsManager.avatarCache imageForKey:noteToSelfCacheKey - diameter:(CGFloat)self.diameter]; + [OWSContactAvatarBuilder.contactsManager getImageFromAvatarCacheWithKey:noteToSelfCacheKey + diameter:(CGFloat)self.diameter]; if (cachedAvatar) { return cachedAvatar; } @@ -167,9 +167,9 @@ NS_ASSUME_NONNULL_BEGIN return nil; } - [OWSContactAvatarBuilder.contactsManager.avatarCache setImage:image - forKey:noteToSelfCacheKey - diameter:self.diameter]; + [OWSContactAvatarBuilder.contactsManager setImageForAvatarCache:image + forKey:noteToSelfCacheKey + diameter:self.diameter]; return image; } @@ -205,7 +205,8 @@ NS_ASSUME_NONNULL_BEGIN - (nullable UIImage *)buildDefaultImage { UIImage *_Nullable cachedAvatar = - [OWSContactAvatarBuilder.contactsManager.avatarCache imageForKey:self.cacheKey diameter:(CGFloat)self.diameter]; + [OWSContactAvatarBuilder.contactsManager getImageFromAvatarCacheWithKey:self.cacheKey + diameter:(CGFloat)self.diameter]; if (cachedAvatar) { return cachedAvatar; } @@ -245,7 +246,7 @@ NS_ASSUME_NONNULL_BEGIN return nil; } - [OWSContactAvatarBuilder.contactsManager.avatarCache setImage:image forKey:self.cacheKey diameter:self.diameter]; + [OWSContactAvatarBuilder.contactsManager setImageForAvatarCache:image forKey:self.cacheKey diameter:self.diameter]; return image; } diff --git a/SignalMessaging/utils/OWSGroupAvatarBuilder.m b/SignalMessaging/utils/OWSGroupAvatarBuilder.m index 1385f57c71..fe1f6b81c6 100644 --- a/SignalMessaging/utils/OWSGroupAvatarBuilder.m +++ b/SignalMessaging/utils/OWSGroupAvatarBuilder.m @@ -67,7 +67,7 @@ NS_ASSUME_NONNULL_BEGIN stringWithFormat:@"%@-%d-%lu", groupId.hexadecimalString, Theme.isDarkThemeEnabled, (unsigned long)diameter]; UIImage *_Nullable cachedAvatar = - [OWSGroupAvatarBuilder.contactsManager.avatarCache imageForKey:cacheKey diameter:(CGFloat)diameter]; + [OWSGroupAvatarBuilder.contactsManager getImageFromAvatarCacheWithKey:cacheKey diameter:(CGFloat)diameter]; if (cachedAvatar) { return cachedAvatar; } @@ -85,7 +85,7 @@ NS_ASSUME_NONNULL_BEGIN return nil; } - [OWSGroupAvatarBuilder.contactsManager.avatarCache setImage:image forKey:cacheKey diameter:diameter]; + [OWSGroupAvatarBuilder.contactsManager setImageForAvatarCache:image forKey:cacheKey diameter:diameter]; return image; }