From eedc9f9a2647c22c8ab6e1ef78224e2c6965aebc Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 1 Nov 2018 11:39:04 -0400 Subject: [PATCH] Sketch out "typing indicators" interaction and cell. --- Signal.xcodeproj/project.pbxproj | 8 ++ .../Cells/TypingIndicatorCell.swift | 37 +++++++++ .../ConversationViewController.m | 2 + .../ConversationView/ConversationViewItem.m | 10 +++ .../ConversationView/ConversationViewModel.m | 83 +++++++++++++++++++ .../TypingIndicatorInteraction.swift | 44 ++++++++++ Signal/src/views/TypingIndicatorView.swift | 4 +- .../src/Messages/Interactions/TSInteraction.h | 4 + .../src/Messages/Interactions/TSInteraction.m | 20 +++++ 9 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift create mode 100644 Signal/src/ViewControllers/ConversationView/TypingIndicatorInteraction.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 5ecf813a3..d7732c32a 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -217,6 +217,8 @@ 34B3F8851E8DF1700035BE1A /* NewGroupViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8551E8DF1700035BE1A /* NewGroupViewController.m */; }; 34B3F8931E8DF1710035BE1A /* SignalsNavigationController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F86E1E8DF1700035BE1A /* SignalsNavigationController.m */; }; 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */; }; + 34B6A905218B4C91007C4606 /* TypingIndicatorInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */; }; + 34B6A907218B5241007C4606 /* TypingIndicatorCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A906218B5240007C4606 /* TypingIndicatorCell.swift */; }; 34B6D27420F664C900765BE2 /* OWSUnreadIndicator.h in Headers */ = {isa = PBXBuildFile; fileRef = 34B6D27220F664C800765BE2 /* OWSUnreadIndicator.h */; settings = {ATTRIBUTES = (Public, ); }; }; 34B6D27520F664C900765BE2 /* OWSUnreadIndicator.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B6D27320F664C800765BE2 /* OWSUnreadIndicator.m */; }; 34BECE2B1F74C12700D7438D /* DebugUIStress.m in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2A1F74C12700D7438D /* DebugUIStress.m */; }; @@ -868,6 +870,8 @@ 34B3F86D1E8DF1700035BE1A /* SignalsNavigationController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SignalsNavigationController.h; sourceTree = ""; }; 34B3F86E1E8DF1700035BE1A /* SignalsNavigationController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SignalsNavigationController.m; sourceTree = ""; }; 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = ""; }; + 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorInteraction.swift; sourceTree = ""; }; + 34B6A906218B5240007C4606 /* TypingIndicatorCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorCell.swift; sourceTree = ""; }; 34B6D27220F664C800765BE2 /* OWSUnreadIndicator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSUnreadIndicator.h; sourceTree = ""; }; 34B6D27320F664C800765BE2 /* OWSUnreadIndicator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSUnreadIndicator.m; sourceTree = ""; }; 34BECE291F74C12700D7438D /* DebugUIStress.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DebugUIStress.h; sourceTree = ""; }; @@ -1575,6 +1579,7 @@ 34D1F0721F8678AA0066283D /* ConversationViewLayout.m */, 341341ED2187467900192D59 /* ConversationViewModel.h */, 341341EE2187467900192D59 /* ConversationViewModel.m */, + 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */, ); path = ConversationView; sourceTree = ""; @@ -1844,6 +1849,7 @@ 34277A5C20751BDC006049F2 /* OWSQuotedMessageView.m */, 34D1F0A51F867BFC0066283D /* OWSSystemMessageCell.h */, 34D1F0A61F867BFC0066283D /* OWSSystemMessageCell.m */, + 34B6A906218B5240007C4606 /* TypingIndicatorCell.swift */, ); path = Cells; sourceTree = ""; @@ -3310,6 +3316,7 @@ 34D99CE4217509C2000AFB39 /* AppEnvironment.swift in Sources */, 348570A820F67575004FF32B /* OWSMessageHeaderView.m in Sources */, 450DF2091E0DD2C6003D14BE /* UserNotificationsAdaptee.swift in Sources */, + 34B6A907218B5241007C4606 /* TypingIndicatorCell.swift in Sources */, 34D1F0AB1F867BFC0066283D /* OWSContactOffersCell.m in Sources */, 340FC8C7204DE64D007AEB0F /* OWSBackupAPI.swift in Sources */, 343A65981FC4CFE7000477A1 /* ConversationScrollButton.m in Sources */, @@ -3388,6 +3395,7 @@ 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */, 340FC8D0205BF2FA007AEB0F /* OWSBackupIO.m in Sources */, 458E38371D668EBF0094BD24 /* OWSDeviceProvisioningURLParser.m in Sources */, + 34B6A905218B4C91007C4606 /* TypingIndicatorInteraction.swift in Sources */, 4517642B1DE939FD00EDB8B9 /* ContactCell.swift in Sources */, 340FC8AB204DAC8D007AEB0F /* DomainFrontingCountryViewController.m in Sources */, 3496744D2076768700080B5F /* OWSMessageBubbleView.m in Sources */, diff --git a/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift b/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift new file mode 100644 index 000000000..5def3dceb --- /dev/null +++ b/Signal/src/ViewControllers/ConversationView/Cells/TypingIndicatorCell.swift @@ -0,0 +1,37 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc(OWSTypingIndicatorCell) +public class TypingIndicatorCell: ConversationViewCell { + + @objc + public static let cellReuseIdentifier = "TypingIndicatorCell" + + @available(*, unavailable, message:"use other constructor instead.") + @objc + public required init(coder aDecoder: NSCoder) { + notImplemented() + } + + override init(frame: CGRect) { + super.init(frame: frame) + } + + @objc + public override func loadForDisplay() { + + } + + @objc + public override func cellSize() -> CGSize { + return .zero + } + + @objc + public override func prepareForReuse() { + super.prepareForReuse() + } +} diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index d313c83a8..571ae69ef 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -596,6 +596,8 @@ typedef enum : NSUInteger { { [self.collectionView registerClass:[OWSSystemMessageCell class] forCellWithReuseIdentifier:[OWSSystemMessageCell cellReuseIdentifier]]; + [self.collectionView registerClass:[OWSTypingIndicatorCell class] + forCellWithReuseIdentifier:[OWSTypingIndicatorCell cellReuseIdentifier]]; [self.collectionView registerClass:[OWSContactOffersCell class] forCellWithReuseIdentifier:[OWSContactOffersCell cellReuseIdentifier]]; [self.collectionView registerClass:[OWSMessageCell class] diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m index e100027ac..6d533ece8 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m @@ -302,6 +302,9 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) case OWSInteractionType_Offer: measurementCell = [OWSContactOffersCell new]; break; + case OWSInteractionType_TypingIndicator: + measurementCell = [OWSTypingIndicatorCell new]; + break; } OWSAssertDebug(measurementCell); @@ -319,6 +322,8 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) return OWSMessageHeaderViewDateHeaderVMargin; } + // TODO: + // "Bubble Collapse". Adjacent messages with the same author should be close together. if (self.interaction.interactionType == OWSInteractionType_IncomingMessage && previousLayoutItem.interaction.interactionType == OWSInteractionType_IncomingMessage) { @@ -359,6 +364,10 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) case OWSInteractionType_Offer: return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSContactOffersCell cellReuseIdentifier] forIndexPath:indexPath]; + + case OWSInteractionType_TypingIndicator: + return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSTypingIndicatorCell cellReuseIdentifier] + forIndexPath:indexPath]; } } @@ -480,6 +489,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) switch (self.interaction.interactionType) { case OWSInteractionType_Unknown: case OWSInteractionType_Offer: + case OWSInteractionType_TypingIndicator: return; case OWSInteractionType_Error: case OWSInteractionType_Info: diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m index d84020efa..042cebc88 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m @@ -143,6 +143,7 @@ static const int kYapDatabaseRangeMinLength = 0; @property (nonatomic, nullable) ThreadDynamicInteractions *dynamicInteractions; @property (nonatomic) BOOL hasClearedUnreadMessagesIndicator; @property (nonatomic, nullable) NSDate *collapseCutoffDate; +@property (nonatomic, nullable) NSString *typingIndicatorsRecipient; @end @@ -200,6 +201,11 @@ static const int kYapDatabaseRangeMinLength = 0; return OWSBlockingManager.sharedManager; } +- (id)typingIndicators +{ + return SSKEnvironment.shared.typingIndicators; +} + #pragma mark - (void)addNotificationListeners @@ -224,6 +230,10 @@ static const int kYapDatabaseRangeMinLength = 0; selector:@selector(signalAccountsDidChange:) name:OWSContactsManagerSignalAccountsDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(typingIndicatorStateDidChange:) + name:[OWSTypingIndicatorsImpl typingIndicatorStateDidChange] + object:nil]; } - (void)signalAccountsDidChange:(NSNotification *)notification @@ -240,6 +250,7 @@ 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.typingIndicatorsRecipient = [self.typingIndicators typingIndicatorsForThread:self.thread]; [self ensureDynamicInteractions]; [self.primaryStorage updateUIDatabaseConnectionToLatest]; @@ -574,6 +585,36 @@ static const int kYapDatabaseRangeMinLength = 0; updatedNeighborItemSet:updatedNeighborItemSet]; } +// A simpler version of the update logic we call when +// only transient items have changed. +- (void)updateForTransientItems +{ + OWSAssertIsOnMainThread(); + + OWSLogVerbose(@""); + + NSMutableArray *oldItemIdList = [NSMutableArray new]; + for (id viewItem in self.viewItems) { + [oldItemIdList addObject:viewItem.itemId]; + } + + NSUInteger oldViewItemCount = self.viewItems.count; + if (![self reloadViewItems]) { + // These errors are rare. + OWSFailDebug(@"could not reload view items; hard resetting message mappings."); + // resetMappings will call delegate.conversationViewModelDidUpdate. + [self resetMappings]; + return; + } + + OWSLogVerbose(@"self.viewItems.count: %zd -> %zd", oldViewItemCount, self.viewItems.count); + + [self updateViewWitholdItemIdList:oldItemIdList + updatedItemSet:[NSSet set] + oldViewItemCount:oldViewItemCount + updatedNeighborItemSet:nil]; +} + - (void)updateViewWithOldItemIdList:(NSArray *)oldItemIdList updatedItemSet:(NSSet *)updatedItemSet updatedNeighborItemSet:(nullable NSMutableSet *)updatedNeighborItemSet @@ -863,6 +904,25 @@ static const int kYapDatabaseRangeMinLength = 0; OWSAssertDebug(!viewItemCache[interaction.uniqueId]); viewItemCache[interaction.uniqueId] = viewItem; } + + if (self.typingIndicatorsRecipient) { + id _Nullable lastViewItem = viewItems.lastObject; + uint64_t typingIndicatorTimestamp = (lastViewItem ? lastViewItem.interaction.timestamp + 1 : 1); + TSInteraction *interaction = + [[OWSTypingIndicatorInteraction alloc] initWithThread:self.thread + timestamp:typingIndicatorTimestamp + recipientId:self.typingIndicatorsRecipient]; + id _Nullable viewItem = self.viewItemCache[interaction.uniqueId]; + if (!viewItem) { + viewItem = [[ConversationInteractionViewItem alloc] initWithInteraction:interaction + isGroupThread:isGroupThread + transaction:transaction + conversationStyle:conversationStyle]; + } + [viewItems addObject:viewItem]; + OWSAssertDebug(!viewItemCache[interaction.uniqueId]); + viewItemCache[interaction.uniqueId] = viewItem; + } }]; // Flag to ensure that we only increment once per launch. @@ -883,6 +943,7 @@ static const int kYapDatabaseRangeMinLength = 0; switch (viewItem.interaction.interactionType) { case OWSInteractionType_Unknown: case OWSInteractionType_Offer: + case OWSInteractionType_TypingIndicator: canShowDate = NO; break; case OWSInteractionType_IncomingMessage: @@ -1276,6 +1337,28 @@ static const int kYapDatabaseRangeMinLength = 0; return @(groupIndex); } +- (void)typingIndicatorStateDidChange:(NSNotification *)notification +{ + OWSAssertIsOnMainThread(); + + self.typingIndicatorsRecipient = [self.typingIndicators typingIndicatorsForThread:self.thread]; +} + +- (void)setTypingIndicatorsRecipient:(nullable NSString *)typingIndicatorsRecipient +{ + OWSAssertIsOnMainThread(); + + BOOL didChange = ![NSObject isNullableObject:typingIndicatorsRecipient equalTo:_typingIndicatorsRecipient]; + + _typingIndicatorsRecipient = typingIndicatorsRecipient; + + // 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]; + } +} + @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/TypingIndicatorInteraction.swift b/Signal/src/ViewControllers/ConversationView/TypingIndicatorInteraction.swift new file mode 100644 index 000000000..2716f9cc5 --- /dev/null +++ b/Signal/src/ViewControllers/ConversationView/TypingIndicatorInteraction.swift @@ -0,0 +1,44 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc(OWSTypingIndicatorInteraction) +public class TypingIndicatorInteraction: TSInteraction { + @objc + public static let TypingIndicatorId = "TypingIndicator" + + @objc + public override func isDynamicInteraction() -> Bool { + return true + } + + @objc + public override func interactionType() -> OWSInteractionType { + return .typingIndicator + } + + @available(*, unavailable, message:"use other constructor instead.") + @objc + public required init(coder aDecoder: NSCoder) { + notImplemented() + } + + @available(*, unavailable, message:"use other constructor instead.") + @objc + public required init(dictionary dictionaryValue: [AnyHashable: Any]!) throws { + notImplemented() + } + + @objc + public let recipientId: String + + @objc + public init(thread: TSThread, timestamp: UInt64, recipientId: String) { + self.recipientId = recipientId + + super.init(interactionWithUniqueId: TypingIndicatorInteraction.TypingIndicatorId, + timestamp: timestamp, in: thread) + } +} diff --git a/Signal/src/views/TypingIndicatorView.swift b/Signal/src/views/TypingIndicatorView.swift index 446c2fb82..3fb4632eb 100644 --- a/Signal/src/views/TypingIndicatorView.swift +++ b/Signal/src/views/TypingIndicatorView.swift @@ -3,7 +3,9 @@ // @objc class TypingIndicatorView: UIStackView { - private let kDotMaxHSpacing: CGFloat = 8 + // This represents the spacing between the dots + // _at their max size_. + private let kDotMaxHSpacing: CGFloat = 3 @objc public static let kMinRadiusPt: CGFloat = 6 diff --git a/SignalServiceKit/src/Messages/Interactions/TSInteraction.h b/SignalServiceKit/src/Messages/Interactions/TSInteraction.h index a5b29b147..58a109bc6 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSInteraction.h +++ b/SignalServiceKit/src/Messages/Interactions/TSInteraction.h @@ -16,6 +16,7 @@ typedef NS_ENUM(NSInteger, OWSInteractionType) { OWSInteractionType_Call, OWSInteractionType_Info, OWSInteractionType_Offer, + OWSInteractionType_TypingIndicator, }; NSString *NSStringFromOWSInteractionType(OWSInteractionType value); @@ -28,6 +29,9 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value); @interface TSInteraction : TSYapDatabaseObject +- (instancetype)initInteractionWithUniqueId:(NSString *)uniqueId + timestamp:(uint64_t)timestamp + inThread:(TSThread *)thread; - (instancetype)initInteractionWithTimestamp:(uint64_t)timestamp inThread:(TSThread *)thread; @property (nonatomic, readonly) NSString *uniqueThreadId; diff --git a/SignalServiceKit/src/Messages/Interactions/TSInteraction.m b/SignalServiceKit/src/Messages/Interactions/TSInteraction.m index 10e4a27bd..ff234c97e 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSInteraction.m +++ b/SignalServiceKit/src/Messages/Interactions/TSInteraction.m @@ -27,6 +27,8 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value) return @"OWSInteractionType_Info"; case OWSInteractionType_Offer: return @"OWSInteractionType_Offer"; + case OWSInteractionType_TypingIndicator: + return @"OWSInteractionType_TypingIndicator"; } } @@ -74,6 +76,24 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value) return @"TSInteraction"; } +- (instancetype)initInteractionWithUniqueId:(NSString *)uniqueId + timestamp:(uint64_t)timestamp + inThread:(TSThread *)thread +{ + OWSAssertDebug(timestamp > 0); + + self = [super initWithUniqueId:uniqueId]; + + if (!self) { + return self; + } + + _timestamp = timestamp; + _uniqueThreadId = thread.uniqueId; + + return self; +} + - (instancetype)initInteractionWithTimestamp:(uint64_t)timestamp inThread:(TSThread *)thread { OWSAssertDebug(timestamp > 0);