Merge branch 'charlesmchen/typingIndicators5_'

pull/1/head
Matthew Chen 7 years ago
commit cc63c5307c

@ -216,6 +216,9 @@
34B3F8821E8DF1700035BE1A /* NewContactThreadViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8501E8DF1700035BE1A /* NewContactThreadViewController.m */; }; 34B3F8821E8DF1700035BE1A /* NewContactThreadViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8501E8DF1700035BE1A /* NewContactThreadViewController.m */; };
34B3F8851E8DF1700035BE1A /* NewGroupViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8551E8DF1700035BE1A /* NewGroupViewController.m */; }; 34B3F8851E8DF1700035BE1A /* NewGroupViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F8551E8DF1700035BE1A /* NewGroupViewController.m */; };
34B3F8931E8DF1710035BE1A /* SignalsNavigationController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B3F86E1E8DF1700035BE1A /* SignalsNavigationController.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, ); }; }; 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 */; }; 34B6D27520F664C900765BE2 /* OWSUnreadIndicator.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B6D27320F664C800765BE2 /* OWSUnreadIndicator.m */; };
34BECE2B1F74C12700D7438D /* DebugUIStress.m in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2A1F74C12700D7438D /* DebugUIStress.m */; }; 34BECE2B1F74C12700D7438D /* DebugUIStress.m in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2A1F74C12700D7438D /* DebugUIStress.m */; };
@ -866,6 +869,9 @@
34B3F8551E8DF1700035BE1A /* NewGroupViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = NewGroupViewController.m; sourceTree = "<group>"; }; 34B3F8551E8DF1700035BE1A /* NewGroupViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = NewGroupViewController.m; sourceTree = "<group>"; };
34B3F86D1E8DF1700035BE1A /* SignalsNavigationController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SignalsNavigationController.h; sourceTree = "<group>"; }; 34B3F86D1E8DF1700035BE1A /* SignalsNavigationController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SignalsNavigationController.h; sourceTree = "<group>"; };
34B3F86E1E8DF1700035BE1A /* SignalsNavigationController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SignalsNavigationController.m; sourceTree = "<group>"; }; 34B3F86E1E8DF1700035BE1A /* SignalsNavigationController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SignalsNavigationController.m; sourceTree = "<group>"; };
34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = "<group>"; };
34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorInteraction.swift; sourceTree = "<group>"; };
34B6A906218B5240007C4606 /* TypingIndicatorCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorCell.swift; sourceTree = "<group>"; };
34B6D27220F664C800765BE2 /* OWSUnreadIndicator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSUnreadIndicator.h; sourceTree = "<group>"; }; 34B6D27220F664C800765BE2 /* OWSUnreadIndicator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSUnreadIndicator.h; sourceTree = "<group>"; };
34B6D27320F664C800765BE2 /* OWSUnreadIndicator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSUnreadIndicator.m; sourceTree = "<group>"; }; 34B6D27320F664C800765BE2 /* OWSUnreadIndicator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSUnreadIndicator.m; sourceTree = "<group>"; };
34BECE291F74C12700D7438D /* DebugUIStress.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DebugUIStress.h; sourceTree = "<group>"; }; 34BECE291F74C12700D7438D /* DebugUIStress.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DebugUIStress.h; sourceTree = "<group>"; };
@ -1573,6 +1579,7 @@
34D1F0721F8678AA0066283D /* ConversationViewLayout.m */, 34D1F0721F8678AA0066283D /* ConversationViewLayout.m */,
341341ED2187467900192D59 /* ConversationViewModel.h */, 341341ED2187467900192D59 /* ConversationViewModel.h */,
341341EE2187467900192D59 /* ConversationViewModel.m */, 341341EE2187467900192D59 /* ConversationViewModel.m */,
34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */,
); );
path = ConversationView; path = ConversationView;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1842,6 +1849,7 @@
34277A5C20751BDC006049F2 /* OWSQuotedMessageView.m */, 34277A5C20751BDC006049F2 /* OWSQuotedMessageView.m */,
34D1F0A51F867BFC0066283D /* OWSSystemMessageCell.h */, 34D1F0A51F867BFC0066283D /* OWSSystemMessageCell.h */,
34D1F0A61F867BFC0066283D /* OWSSystemMessageCell.m */, 34D1F0A61F867BFC0066283D /* OWSSystemMessageCell.m */,
34B6A906218B5240007C4606 /* TypingIndicatorCell.swift */,
); );
path = Cells; path = Cells;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2215,10 +2223,11 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */, 452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */,
4C2F454E214C00E1004871FF /* AvatarTableViewCell.swift */,
34E3E5671EC4B19400495BAC /* AudioProgressView.swift */, 34E3E5671EC4B19400495BAC /* AudioProgressView.swift */,
4C2F454E214C00E1004871FF /* AvatarTableViewCell.swift */,
451764291DE939FD00EDB8B9 /* ContactCell.swift */, 451764291DE939FD00EDB8B9 /* ContactCell.swift */,
4523149F1F7E9E18003A428C /* DirectionalPanGestureRecognizer.swift */, 4523149F1F7E9E18003A428C /* DirectionalPanGestureRecognizer.swift */,
4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */,
45A663C41F92EC760027B59E /* GroupTableViewCell.swift */, 45A663C41F92EC760027B59E /* GroupTableViewCell.swift */,
45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */, 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */,
34386A53207D271C009F5D9C /* NeverClearView.swift */, 34386A53207D271C009F5D9C /* NeverClearView.swift */,
@ -2234,8 +2243,8 @@
45A6DAD51EBBF85500893231 /* ReminderView.swift */, 45A6DAD51EBBF85500893231 /* ReminderView.swift */,
450D19111F85236600970622 /* RemoteVideoView.h */, 450D19111F85236600970622 /* RemoteVideoView.h */,
450D19121F85236600970622 /* RemoteVideoView.m */, 450D19121F85236600970622 /* RemoteVideoView.m */,
4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */,
4CA5F792211E1F06008C2708 /* Toast.swift */, 4CA5F792211E1F06008C2708 /* Toast.swift */,
34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */,
); );
name = Views; name = Views;
path = views; path = views;
@ -3307,6 +3316,7 @@
34D99CE4217509C2000AFB39 /* AppEnvironment.swift in Sources */, 34D99CE4217509C2000AFB39 /* AppEnvironment.swift in Sources */,
348570A820F67575004FF32B /* OWSMessageHeaderView.m in Sources */, 348570A820F67575004FF32B /* OWSMessageHeaderView.m in Sources */,
450DF2091E0DD2C6003D14BE /* UserNotificationsAdaptee.swift in Sources */, 450DF2091E0DD2C6003D14BE /* UserNotificationsAdaptee.swift in Sources */,
34B6A907218B5241007C4606 /* TypingIndicatorCell.swift in Sources */,
34D1F0AB1F867BFC0066283D /* OWSContactOffersCell.m in Sources */, 34D1F0AB1F867BFC0066283D /* OWSContactOffersCell.m in Sources */,
340FC8C7204DE64D007AEB0F /* OWSBackupAPI.swift in Sources */, 340FC8C7204DE64D007AEB0F /* OWSBackupAPI.swift in Sources */,
343A65981FC4CFE7000477A1 /* ConversationScrollButton.m in Sources */, 343A65981FC4CFE7000477A1 /* ConversationScrollButton.m in Sources */,
@ -3382,8 +3392,10 @@
457C87B82032645C008D52D6 /* DebugUINotifications.swift in Sources */, 457C87B82032645C008D52D6 /* DebugUINotifications.swift in Sources */,
4C13C9F620E57BA30089A98B /* ColorPickerViewController.swift in Sources */, 4C13C9F620E57BA30089A98B /* ColorPickerViewController.swift in Sources */,
4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */, 4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */,
34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */,
340FC8D0205BF2FA007AEB0F /* OWSBackupIO.m in Sources */, 340FC8D0205BF2FA007AEB0F /* OWSBackupIO.m in Sources */,
458E38371D668EBF0094BD24 /* OWSDeviceProvisioningURLParser.m in Sources */, 458E38371D668EBF0094BD24 /* OWSDeviceProvisioningURLParser.m in Sources */,
34B6A905218B4C91007C4606 /* TypingIndicatorInteraction.swift in Sources */,
4517642B1DE939FD00EDB8B9 /* ContactCell.swift in Sources */, 4517642B1DE939FD00EDB8B9 /* ContactCell.swift in Sources */,
340FC8AB204DAC8D007AEB0F /* DomainFrontingCountryViewController.m in Sources */, 340FC8AB204DAC8D007AEB0F /* DomainFrontingCountryViewController.m in Sources */,
3496744D2076768700080B5F /* OWSMessageBubbleView.m in Sources */, 3496744D2076768700080B5F /* OWSMessageBubbleView.m in Sources */,

@ -53,6 +53,8 @@ typedef NS_OPTIONS(NSUInteger, OWSDirectionalRectCorner) {
- (CGFloat)minWidth; - (CGFloat)minWidth;
- (CGFloat)minHeight;
@end @end
NS_ASSUME_NONNULL_END NS_ASSUME_NONNULL_END

@ -273,6 +273,11 @@ const CGFloat kOWSMessageCellCornerRadius_Small = 4;
return (kOWSMessageCellCornerRadius_Large * 2); return (kOWSMessageCellCornerRadius_Large * 2);
} }
- (CGFloat)minHeight
{
return (kOWSMessageCellCornerRadius_Large * 2);
}
@end @end
NS_ASSUME_NONNULL_END NS_ASSUME_NONNULL_END

@ -0,0 +1,148 @@
//
// 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()
}
private let kAvatarSize: CGFloat = 36
private let kAvatarHSpacing: CGFloat = 8
private let avatarView = AvatarImageView()
private let bubbleView = OWSBubbleView()
private let typingIndicatorView = TypingIndicatorView()
private var viewConstraints = [NSLayoutConstraint]()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
private func commonInit() {
self.layoutMargins = .zero
self.contentView.layoutMargins = .zero
bubbleView.layoutMargins = .zero
bubbleView.addSubview(typingIndicatorView)
contentView.addSubview(bubbleView)
avatarView.autoSetDimension(.width, toSize: kAvatarSize)
avatarView.autoSetDimension(.height, toSize: kAvatarSize)
}
@objc
public override func loadForDisplay() {
guard let conversationStyle = self.conversationStyle else {
owsFailDebug("Missing conversationStyle")
return
}
bubbleView.bubbleColor = conversationStyle.bubbleColor(isIncoming: true)
typingIndicatorView.startAnimation()
viewConstraints.append(contentsOf: [
bubbleView.autoPinEdge(toSuperviewEdge: .leading, withInset: conversationStyle.gutterLeading),
bubbleView.autoPinEdge(toSuperviewEdge: .trailing, withInset: conversationStyle.gutterTrailing, relation: .greaterThanOrEqual),
bubbleView.autoPinTopToSuperviewMargin(withInset: 0),
bubbleView.autoPinBottomToSuperviewMargin(withInset: 0),
typingIndicatorView.autoPinEdge(toSuperviewEdge: .leading, withInset: conversationStyle.textInsetHorizontal),
typingIndicatorView.autoPinEdge(toSuperviewEdge: .trailing, withInset: conversationStyle.textInsetHorizontal),
typingIndicatorView.autoPinTopToSuperviewMargin(withInset: conversationStyle.textInsetTop),
typingIndicatorView.autoPinBottomToSuperviewMargin(withInset: conversationStyle.textInsetBottom)
])
if let avatarView = configureAvatarView() {
contentView.addSubview(avatarView)
viewConstraints.append(contentsOf: [
bubbleView.autoPinLeading(toTrailingEdgeOf: avatarView, offset: kAvatarHSpacing),
bubbleView.autoAlignAxis(.horizontal, toSameAxisOf: avatarView)
])
} else {
avatarView.removeFromSuperview()
}
}
private func configureAvatarView() -> UIView? {
guard let viewItem = self.viewItem else {
owsFailDebug("Missing viewItem")
return nil
}
guard let typingIndicators = viewItem.interaction as? TypingIndicatorInteraction else {
owsFailDebug("Missing typingIndicators")
return nil
}
guard shouldShowAvatar() else {
return nil
}
guard let colorName = viewItem.authorConversationColorName else {
owsFailDebug("Missing authorConversationColorName")
return nil
}
guard let authorAvatarImage =
OWSContactAvatarBuilder(signalId: typingIndicators.recipientId,
colorName: ConversationColorName(rawValue: colorName),
diameter: UInt(kAvatarSize)).build() else {
owsFailDebug("Could build avatar image")
return nil
}
avatarView.image = authorAvatarImage
return avatarView
}
private func shouldShowAvatar() -> Bool {
guard let viewItem = self.viewItem else {
owsFailDebug("Missing viewItem")
return false
}
return viewItem.isGroupThread
}
@objc
public override func cellSize() -> CGSize {
guard let conversationStyle = self.conversationStyle else {
owsFailDebug("Missing conversationStyle")
return .zero
}
let insetsSize = CGSize(width: conversationStyle.textInsetHorizontal * 2,
height: conversationStyle.textInsetTop + conversationStyle.textInsetBottom)
let typingIndicatorSize = typingIndicatorView.sizeThatFits(.zero)
let bubbleSize = CGSizeAdd(insetsSize, typingIndicatorSize)
if shouldShowAvatar() {
let avatarSize = CGSize(width: kAvatarSize, height: kAvatarSize)
return CGSizeCeil(CGSize(width: avatarSize.width + kAvatarHSpacing + bubbleSize.width,
height: max(avatarSize.height, bubbleSize.height)))
} else {
return bubbleSize
}
}
@objc
public override func prepareForReuse() {
super.prepareForReuse()
NSLayoutConstraint.deactivate(viewConstraints)
viewConstraints = [NSLayoutConstraint]()
avatarView.image = nil
avatarView.removeFromSuperview()
typingIndicatorView.stopAnimation()
}
}

@ -596,6 +596,8 @@ typedef enum : NSUInteger {
{ {
[self.collectionView registerClass:[OWSSystemMessageCell class] [self.collectionView registerClass:[OWSSystemMessageCell class]
forCellWithReuseIdentifier:[OWSSystemMessageCell cellReuseIdentifier]]; forCellWithReuseIdentifier:[OWSSystemMessageCell cellReuseIdentifier]];
[self.collectionView registerClass:[OWSTypingIndicatorCell class]
forCellWithReuseIdentifier:[OWSTypingIndicatorCell cellReuseIdentifier]];
[self.collectionView registerClass:[OWSContactOffersCell class] [self.collectionView registerClass:[OWSContactOffersCell class]
forCellWithReuseIdentifier:[OWSContactOffersCell cellReuseIdentifier]]; forCellWithReuseIdentifier:[OWSContactOffersCell cellReuseIdentifier]];
[self.collectionView registerClass:[OWSMessageCell class] [self.collectionView registerClass:[OWSMessageCell class]

@ -143,14 +143,24 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
{ {
OWSAssertDebug(transaction); OWSAssertDebug(transaction);
if (self.interaction.interactionType != OWSInteractionType_IncomingMessage) { switch (self.interaction.interactionType) {
_authorConversationColorName = nil; case OWSInteractionType_TypingIndicator: {
return; OWSTypingIndicatorInteraction *typingIndicator = (OWSTypingIndicatorInteraction *)self.interaction;
_authorConversationColorName =
[TSContactThread conversationColorNameForRecipientId:typingIndicator.recipientId
transaction:transaction];
break;
}
case OWSInteractionType_IncomingMessage: {
TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.interaction;
_authorConversationColorName =
[TSContactThread conversationColorNameForRecipientId:incomingMessage.authorId transaction:transaction];
break;
}
default:
_authorConversationColorName = nil;
break;
} }
TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.interaction;
_authorConversationColorName =
[TSContactThread conversationColorNameForRecipientId:incomingMessage.authorId transaction:transaction];
} }
- (NSString *)itemId - (NSString *)itemId
@ -302,6 +312,9 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
case OWSInteractionType_Offer: case OWSInteractionType_Offer:
measurementCell = [OWSContactOffersCell new]; measurementCell = [OWSContactOffersCell new];
break; break;
case OWSInteractionType_TypingIndicator:
measurementCell = [OWSTypingIndicatorCell new];
break;
} }
OWSAssertDebug(measurementCell); OWSAssertDebug(measurementCell);
@ -359,6 +372,10 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
case OWSInteractionType_Offer: case OWSInteractionType_Offer:
return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSContactOffersCell cellReuseIdentifier] return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSContactOffersCell cellReuseIdentifier]
forIndexPath:indexPath]; forIndexPath:indexPath];
case OWSInteractionType_TypingIndicator:
return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSTypingIndicatorCell cellReuseIdentifier]
forIndexPath:indexPath];
} }
} }
@ -480,6 +497,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
switch (self.interaction.interactionType) { switch (self.interaction.interactionType) {
case OWSInteractionType_Unknown: case OWSInteractionType_Unknown:
case OWSInteractionType_Offer: case OWSInteractionType_Offer:
case OWSInteractionType_TypingIndicator:
return; return;
case OWSInteractionType_Error: case OWSInteractionType_Error:
case OWSInteractionType_Info: case OWSInteractionType_Info:

@ -143,6 +143,7 @@ static const int kYapDatabaseRangeMinLength = 0;
@property (nonatomic, nullable) ThreadDynamicInteractions *dynamicInteractions; @property (nonatomic, nullable) ThreadDynamicInteractions *dynamicInteractions;
@property (nonatomic) BOOL hasClearedUnreadMessagesIndicator; @property (nonatomic) BOOL hasClearedUnreadMessagesIndicator;
@property (nonatomic, nullable) NSDate *collapseCutoffDate; @property (nonatomic, nullable) NSDate *collapseCutoffDate;
@property (nonatomic, nullable) NSString *typingIndicatorsSender;
@end @end
@ -200,6 +201,11 @@ static const int kYapDatabaseRangeMinLength = 0;
return OWSBlockingManager.sharedManager; return OWSBlockingManager.sharedManager;
} }
- (id<OWSTypingIndicators>)typingIndicators
{
return SSKEnvironment.shared.typingIndicators;
}
#pragma mark #pragma mark
- (void)addNotificationListeners - (void)addNotificationListeners
@ -224,6 +230,10 @@ static const int kYapDatabaseRangeMinLength = 0;
selector:@selector(signalAccountsDidChange:) selector:@selector(signalAccountsDidChange:)
name:OWSContactsManagerSignalAccountsDidChangeNotification name:OWSContactsManagerSignalAccountsDidChangeNotification
object:nil]; object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(typingIndicatorStateDidChange:)
name:[OWSTypingIndicatorsImpl typingIndicatorStateDidChange]
object:nil];
} }
- (void)signalAccountsDidChange:(NSNotification *)notification - (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 // We need to update the "unread indicator" _before_ we determine the initial range
// size, since it depends on where the unread indicator is placed. // size, since it depends on where the unread indicator is placed.
self.lastRangeLength = 0; self.lastRangeLength = 0;
self.typingIndicatorsSender = [self.typingIndicators typingRecipientIdForThread:self.thread];
[self ensureDynamicInteractions]; [self ensureDynamicInteractions];
[self.primaryStorage updateUIDatabaseConnectionToLatest]; [self.primaryStorage updateUIDatabaseConnectionToLatest];
@ -574,6 +585,32 @@ static const int kYapDatabaseRangeMinLength = 0;
updatedNeighborItemSet:updatedNeighborItemSet]; updatedNeighborItemSet:updatedNeighborItemSet];
} }
// A simpler version of the update logic we use when
// only transient items have changed.
- (void)updateForTransientItems
{
OWSAssertIsOnMainThread();
OWSLogVerbose(@"");
NSMutableArray<NSString *> *oldItemIdList = [NSMutableArray new];
for (id<ConversationViewItem> viewItem in self.viewItems) {
[oldItemIdList addObject:viewItem.itemId];
}
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", oldItemIdList.count, self.viewItems.count);
[self updateViewWithOldItemIdList:oldItemIdList updatedItemSet:[NSSet set] updatedNeighborItemSet:nil];
}
- (void)updateViewWithOldItemIdList:(NSArray<NSString *> *)oldItemIdList - (void)updateViewWithOldItemIdList:(NSArray<NSString *> *)oldItemIdList
updatedItemSet:(NSSet<NSString *> *)updatedItemSet updatedItemSet:(NSSet<NSString *> *)updatedItemSet
updatedNeighborItemSet:(nullable NSMutableSet<NSString *> *)updatedNeighborItemSet updatedNeighborItemSet:(nullable NSMutableSet<NSString *> *)updatedNeighborItemSet
@ -863,6 +900,25 @@ static const int kYapDatabaseRangeMinLength = 0;
OWSAssertDebug(!viewItemCache[interaction.uniqueId]); OWSAssertDebug(!viewItemCache[interaction.uniqueId]);
viewItemCache[interaction.uniqueId] = viewItem; viewItemCache[interaction.uniqueId] = viewItem;
} }
if (self.typingIndicatorsSender) {
id<ConversationViewItem> _Nullable lastViewItem = viewItems.lastObject;
uint64_t typingIndicatorTimestamp = (lastViewItem ? lastViewItem.interaction.timestamp + 1 : 1);
TSInteraction *interaction =
[[OWSTypingIndicatorInteraction alloc] initWithThread:self.thread
timestamp:typingIndicatorTimestamp
recipientId:self.typingIndicatorsSender];
id<ConversationViewItem> _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. // Flag to ensure that we only increment once per launch.
@ -883,6 +939,7 @@ static const int kYapDatabaseRangeMinLength = 0;
switch (viewItem.interaction.interactionType) { switch (viewItem.interaction.interactionType) {
case OWSInteractionType_Unknown: case OWSInteractionType_Unknown:
case OWSInteractionType_Offer: case OWSInteractionType_Offer:
case OWSInteractionType_TypingIndicator:
canShowDate = NO; canShowDate = NO;
break; break;
case OWSInteractionType_IncomingMessage: case OWSInteractionType_IncomingMessage:
@ -1276,6 +1333,34 @@ static const int kYapDatabaseRangeMinLength = 0;
return @(groupIndex); return @(groupIndex);
} }
- (void)typingIndicatorStateDidChange:(NSNotification *)notification
{
OWSAssertIsOnMainThread();
OWSAssertDebug([notification.object isKindOfClass:[NSString class]]);
OWSAssertDebug(self.thread);
if (![notification.object isEqual:self.thread.uniqueId]) {
return;
}
self.typingIndicatorsSender = [self.typingIndicators typingRecipientIdForThread:self.thread];
}
- (void)setTypingIndicatorsSender:(nullable NSString *)typingIndicatorsSender
{
OWSAssertIsOnMainThread();
BOOL didChange = ![NSObject isNullableObject:typingIndicatorsSender equalTo:_typingIndicatorsSender];
_typingIndicatorsSender = typingIndicatorsSender;
// 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 @end
NS_ASSUME_NONNULL_END NS_ASSUME_NONNULL_END

@ -0,0 +1,49 @@
//
// 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)
}
@objc
public override func save(with transaction: YapDatabaseReadWriteTransaction!) {
owsFailDebug("The transient interaction should not be saved in the database.")
}
}

@ -22,11 +22,14 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic) UILabel *snippetLabel; @property (nonatomic) UILabel *snippetLabel;
@property (nonatomic) UILabel *dateTimeLabel; @property (nonatomic) UILabel *dateTimeLabel;
@property (nonatomic) MessageStatusView *messageStatusView; @property (nonatomic) MessageStatusView *messageStatusView;
@property (nonatomic) TypingIndicatorView *typingIndicatorView;
@property (nonatomic) UIView *unreadBadge; @property (nonatomic) UIView *unreadBadge;
@property (nonatomic) UILabel *unreadLabel; @property (nonatomic) UILabel *unreadLabel;
@property (nonatomic, nullable) ThreadViewModel *thread; @property (nonatomic, nullable) ThreadViewModel *thread;
@property (nonatomic, nullable) NSAttributedString *overrideSnippet;
@property (nonatomic) BOOL isBlocked;
@property (nonatomic, readonly) NSMutableArray<NSLayoutConstraint *> *viewConstraints; @property (nonatomic, readonly) NSMutableArray<NSLayoutConstraint *> *viewConstraints;
@ -45,6 +48,11 @@ NS_ASSUME_NONNULL_BEGIN
return Environment.shared.contactsManager; return Environment.shared.contactsManager;
} }
- (id<OWSTypingIndicators>)typingIndicators
{
return SSKEnvironment.shared.typingIndicators;
}
#pragma mark - #pragma mark -
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(nullable NSString *)reuseIdentifier - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(nullable NSString *)reuseIdentifier
@ -114,10 +122,14 @@ NS_ASSUME_NONNULL_BEGIN
[self.snippetLabel setContentHuggingHorizontalLow]; [self.snippetLabel setContentHuggingHorizontalLow];
[self.snippetLabel setCompressionResistanceHorizontalLow]; [self.snippetLabel setCompressionResistanceHorizontalLow];
self.typingIndicatorView = [TypingIndicatorView new];
[self.contentView addSubview:self.typingIndicatorView];
UIStackView *bottomRowView = [[UIStackView alloc] initWithArrangedSubviews:@[ UIStackView *bottomRowView = [[UIStackView alloc] initWithArrangedSubviews:@[
self.snippetLabel, self.snippetLabel,
self.messageStatusView, self.messageStatusView,
]]; ]];
bottomRowView.axis = UILayoutConstraintAxisHorizontal; bottomRowView.axis = UILayoutConstraintAxisHorizontal;
bottomRowView.alignment = UIStackViewAlignmentLastBaseline; bottomRowView.alignment = UIStackViewAlignmentLastBaseline;
bottomRowView.spacing = 6.f; bottomRowView.spacing = 6.f;
@ -154,6 +166,9 @@ NS_ASSUME_NONNULL_BEGIN
[self.contentView addSubview:self.unreadBadge]; [self.contentView addSubview:self.unreadBadge];
[self.unreadBadge autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.nameLabel]; [self.unreadBadge autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.nameLabel];
[self.typingIndicatorView autoPinEdge:ALEdgeLeading toEdge:ALEdgeLeading ofView:self.snippetLabel];
[self.typingIndicatorView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.snippetLabel];
} }
- (void)dealloc - (void)dealloc
@ -196,6 +211,8 @@ NS_ASSUME_NONNULL_BEGIN
[OWSTableItem configureCell:self]; [OWSTableItem configureCell:self];
self.thread = thread; self.thread = thread;
self.overrideSnippet = overrideSnippet;
self.isBlocked = isBlocked;
BOOL hasUnreadMessages = thread.hasUnreadMessages; BOOL hasUnreadMessages = thread.hasUnreadMessages;
@ -203,6 +220,10 @@ NS_ASSUME_NONNULL_BEGIN
selector:@selector(otherUsersProfileDidChange:) selector:@selector(otherUsersProfileDidChange:)
name:kNSNotificationName_OtherUsersProfileDidChange name:kNSNotificationName_OtherUsersProfileDidChange
object:nil]; object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(typingIndicatorStateDidChange:)
name:[OWSTypingIndicatorsImpl typingIndicatorStateDidChange]
object:nil];
[self updateNameLabel]; [self updateNameLabel];
[self updateAvatarView]; [self updateAvatarView];
@ -210,11 +231,7 @@ NS_ASSUME_NONNULL_BEGIN
// changes to the dynamic type settings are reflected. // changes to the dynamic type settings are reflected.
self.snippetLabel.font = [self snippetFont]; self.snippetLabel.font = [self snippetFont];
if (overrideSnippet) { [self updatePreview];
self.snippetLabel.attributedText = overrideSnippet;
} else {
self.snippetLabel.attributedText = [self attributedSnippetForThread:thread isBlocked:isBlocked];
}
self.dateTimeLabel.text self.dateTimeLabel.text
= (overrideDate ? [self stringForDate:overrideDate] : [self stringForDate:thread.lastMessageDate]); = (overrideDate ? [self stringForDate:overrideDate] : [self stringForDate:thread.lastMessageDate]);
@ -442,6 +459,7 @@ NS_ASSUME_NONNULL_BEGIN
[self.viewConstraints removeAllObjects]; [self.viewConstraints removeAllObjects];
self.thread = nil; self.thread = nil;
self.overrideSnippet = nil;
self.avatarView.image = nil; self.avatarView.image = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self]; [[NSNotificationCenter defaultCenter] removeObserver:self];
@ -500,6 +518,43 @@ NS_ASSUME_NONNULL_BEGIN
self.nameLabel.attributedText = name; self.nameLabel.attributedText = name;
} }
#pragma mark - Typing Indicators
- (void)updatePreview
{
if ([self.typingIndicators typingRecipientIdForThread:self.thread.threadRecord] != nil) {
// If we hide snippetLabel, our layout will break since UIStackView will remove
// it from the layout. Wrapping the preview views (the snippet label and the
// typing indicator) in a UIStackView proved non-trivial since we're using
// UIStackViewAlignmentLastBaseline. Therefore we hide the _contents_ of the
// snippet label using an empty string.
self.snippetLabel.text = @" ";
self.typingIndicatorView.hidden = NO;
[self.typingIndicatorView startAnimation];
} else {
if (self.overrideSnippet) {
self.snippetLabel.attributedText = self.overrideSnippet;
} else {
self.snippetLabel.attributedText = [self attributedSnippetForThread:self.thread isBlocked:self.isBlocked];
}
self.typingIndicatorView.hidden = YES;
[self.typingIndicatorView stopAnimation];
}
}
- (void)typingIndicatorStateDidChange:(NSNotification *)notification
{
OWSAssertIsOnMainThread();
OWSAssertDebug([notification.object isKindOfClass:[NSString class]]);
OWSAssertDebug(self.thread);
if (![notification.object isEqual:self.thread.threadRecord.uniqueId]) {
return;
}
[self updatePreview];
}
@end @end
NS_ASSUME_NONNULL_END NS_ASSUME_NONNULL_END

@ -0,0 +1,180 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
@objc class TypingIndicatorView: UIStackView {
// This represents the spacing between the dots
// _at their max size_.
private let kDotMaxHSpacing: CGFloat = 3
@objc
public static let kMinRadiusPt: CGFloat = 6
@objc
public static let kMaxRadiusPt: CGFloat = 8
private let dot1 = DotView(dotType: .dotType1)
private let dot2 = DotView(dotType: .dotType2)
private let dot3 = DotView(dotType: .dotType3)
@available(*, unavailable, message:"use other constructor instead.")
required init(coder aDecoder: NSCoder) {
notImplemented()
}
@available(*, unavailable, message:"use other constructor instead.")
override init(frame: CGRect) {
notImplemented()
}
@objc
public init() {
super.init(frame: .zero)
// init(arrangedSubviews:...) is not a designated initializer.
for dot in dots() {
addArrangedSubview(dot)
}
self.axis = .horizontal
self.spacing = kDotMaxHSpacing
self.alignment = .center
}
@objc
public override func sizeThatFits(_ size: CGSize) -> CGSize {
return CGSize(width: TypingIndicatorView.kMaxRadiusPt * 3 + kDotMaxHSpacing * 2, height: TypingIndicatorView.kMaxRadiusPt)
}
private func dots() -> [DotView] {
return [dot1, dot2, dot3]
}
@objc
public func startAnimation() {
for dot in dots() {
dot.startAnimation()
}
}
@objc
public func stopAnimation() {
for dot in dots() {
dot.stopAnimation()
}
}
private enum DotType {
case dotType1
case dotType2
case dotType3
}
private class DotView: UIView {
private let dotType: DotType
private let shapeLayer = CAShapeLayer()
@available(*, unavailable, message:"use other constructor instead.")
required init?(coder aDecoder: NSCoder) {
notImplemented()
}
@available(*, unavailable, message:"use other constructor instead.")
override init(frame: CGRect) {
notImplemented()
}
init(dotType: DotType) {
self.dotType = dotType
super.init(frame: .zero)
autoSetDimension(.width, toSize: kMaxRadiusPt)
autoSetDimension(.height, toSize: kMaxRadiusPt)
layer.addSublayer(shapeLayer)
}
fileprivate func startAnimation() {
stopAnimation()
let baseColor = (Theme.isDarkThemeEnabled
? UIColor(rgbHex: 0xBBBDBE)
: UIColor(rgbHex: 0x636467))
let timeIncrement: CFTimeInterval = 0.15
var colorValues = [CGColor]()
var pathValues = [CGPath]()
var keyTimes = [CFTimeInterval]()
var animationDuration: CFTimeInterval = 0
let addDotKeyFrame = { (keyFrameTime: CFTimeInterval, progress: CGFloat) in
let dotColor = baseColor.withAlphaComponent(CGFloatLerp(0.4, 1.0, progress))
colorValues.append(dotColor.cgColor)
let radius = CGFloatLerp(TypingIndicatorView.kMinRadiusPt, TypingIndicatorView.kMaxRadiusPt, progress)
let margin = (TypingIndicatorView.kMaxRadiusPt - radius) * 0.5
let bezierPath = UIBezierPath(ovalIn: CGRect(x: margin, y: margin, width: radius, height: radius))
pathValues.append(bezierPath.cgPath)
keyTimes.append(keyFrameTime)
animationDuration = max(animationDuration, keyFrameTime)
}
// All animations in the group apparently need to have the same number
// of keyframes, and use the same timing.
switch dotType {
case .dotType1:
addDotKeyFrame(0 * timeIncrement, 0.0)
addDotKeyFrame(1 * timeIncrement, 0.5)
addDotKeyFrame(2 * timeIncrement, 1.0)
addDotKeyFrame(3 * timeIncrement, 0.5)
addDotKeyFrame(4 * timeIncrement, 0.0)
addDotKeyFrame(5 * timeIncrement, 0.0)
addDotKeyFrame(6 * timeIncrement, 0.0)
addDotKeyFrame(10 * timeIncrement, 0.0)
break
case .dotType2:
addDotKeyFrame(0 * timeIncrement, 0.0)
addDotKeyFrame(1 * timeIncrement, 0.0)
addDotKeyFrame(2 * timeIncrement, 0.5)
addDotKeyFrame(3 * timeIncrement, 1.0)
addDotKeyFrame(4 * timeIncrement, 0.5)
addDotKeyFrame(5 * timeIncrement, 0.0)
addDotKeyFrame(6 * timeIncrement, 0.0)
addDotKeyFrame(10 * timeIncrement, 0.0)
break
case .dotType3:
addDotKeyFrame(0 * timeIncrement, 0.0)
addDotKeyFrame(1 * timeIncrement, 0.0)
addDotKeyFrame(2 * timeIncrement, 0.0)
addDotKeyFrame(3 * timeIncrement, 0.5)
addDotKeyFrame(4 * timeIncrement, 1.0)
addDotKeyFrame(5 * timeIncrement, 0.5)
addDotKeyFrame(6 * timeIncrement, 0.0)
addDotKeyFrame(10 * timeIncrement, 0.0)
break
}
let makeAnimation: (String, [Any]) -> CAKeyframeAnimation = { (keyPath, values) in
let animation = CAKeyframeAnimation()
animation.keyPath = keyPath
animation.values = values
animation.duration = animationDuration
return animation
}
let groupAnimation = CAAnimationGroup()
groupAnimation.animations = [
makeAnimation("fillColor", colorValues),
makeAnimation("path", pathValues)
]
groupAnimation.duration = animationDuration
groupAnimation.repeatCount = MAXFLOAT
shapeLayer.add(groupAnimation, forKey: UUID().uuidString)
}
fileprivate func stopAnimation() {
shapeLayer.removeAllAnimations()
}
}
}

@ -193,6 +193,11 @@ CG_INLINE CGSize CGSizeScale(CGSize size, CGFloat factor)
return CGSizeMake(size.width * factor, size.height * factor); return CGSizeMake(size.width * factor, size.height * factor);
} }
CG_INLINE CGSize CGSizeAdd(CGSize left, CGSize right)
{
return CGSizeMake(left.width + right.width, left.height + right.height);
}
CGFloat CGHairlineWidth(void); CGFloat CGHairlineWidth(void);
NS_ASSUME_NONNULL_END NS_ASSUME_NONNULL_END

@ -11,6 +11,7 @@ NS_ASSUME_NONNULL_BEGIN
@class TSInvalidIdentityKeyReceivingErrorMessage; @class TSInvalidIdentityKeyReceivingErrorMessage;
typedef NSString *ConversationColorName NS_STRING_ENUM; typedef NSString *ConversationColorName NS_STRING_ENUM;
extern ConversationColorName const ConversationColorNameCrimson; extern ConversationColorName const ConversationColorNameCrimson;
extern ConversationColorName const ConversationColorNameVermilion; extern ConversationColorName const ConversationColorNameVermilion;
extern ConversationColorName const ConversationColorNameBurlap; extern ConversationColorName const ConversationColorNameBurlap;

@ -16,6 +16,7 @@ typedef NS_ENUM(NSInteger, OWSInteractionType) {
OWSInteractionType_Call, OWSInteractionType_Call,
OWSInteractionType_Info, OWSInteractionType_Info,
OWSInteractionType_Offer, OWSInteractionType_Offer,
OWSInteractionType_TypingIndicator,
}; };
NSString *NSStringFromOWSInteractionType(OWSInteractionType value); NSString *NSStringFromOWSInteractionType(OWSInteractionType value);
@ -28,6 +29,9 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value);
@interface TSInteraction : TSYapDatabaseObject @interface TSInteraction : TSYapDatabaseObject
- (instancetype)initInteractionWithUniqueId:(NSString *)uniqueId
timestamp:(uint64_t)timestamp
inThread:(TSThread *)thread;
- (instancetype)initInteractionWithTimestamp:(uint64_t)timestamp inThread:(TSThread *)thread; - (instancetype)initInteractionWithTimestamp:(uint64_t)timestamp inThread:(TSThread *)thread;
@property (nonatomic, readonly) NSString *uniqueThreadId; @property (nonatomic, readonly) NSString *uniqueThreadId;

@ -27,6 +27,8 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value)
return @"OWSInteractionType_Info"; return @"OWSInteractionType_Info";
case OWSInteractionType_Offer: case OWSInteractionType_Offer:
return @"OWSInteractionType_Offer"; return @"OWSInteractionType_Offer";
case OWSInteractionType_TypingIndicator:
return @"OWSInteractionType_TypingIndicator";
} }
} }
@ -74,6 +76,24 @@ NSString *NSStringFromOWSInteractionType(OWSInteractionType value)
return @"TSInteraction"; 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 - (instancetype)initInteractionWithTimestamp:(uint64_t)timestamp inThread:(TSThread *)thread
{ {
OWSAssertDebug(timestamp > 0); OWSAssertDebug(timestamp > 0);

@ -24,9 +24,14 @@ public protocol TypingIndicators: class {
@objc @objc
func didReceiveIncomingMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) func didReceiveIncomingMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt)
// Returns the recipient id of the user who should currently be shown typing for a given thread.
//
// If no one is typing in that thread, returns nil.
// If multiple users are typing in that thread, returns the user to show.
//
// TODO: Use this method. // TODO: Use this method.
@objc @objc
func areTypingIndicatorsVisible(inThread thread: TSThread, recipientId: String) -> Bool func typingRecipientId(forThread thread: TSThread) -> String?
@objc @objc
func setTypingIndicatorsEnabled(value: Bool) func setTypingIndicatorsEnabled(value: Bool)
@ -40,7 +45,8 @@ public protocol TypingIndicators: class {
@objc(OWSTypingIndicatorsImpl) @objc(OWSTypingIndicatorsImpl)
public class TypingIndicatorsImpl: NSObject, TypingIndicators { public class TypingIndicatorsImpl: NSObject, TypingIndicators {
@objc public static let typingIndicatorStateDidChange = Notification.Name("typingIndicatorStateDidChange") @objc
public static let typingIndicatorStateDidChange = Notification.Name("typingIndicatorStateDidChange")
private let kDatabaseCollection = "TypingIndicators" private let kDatabaseCollection = "TypingIndicators"
private let kDatabaseKey_TypingIndicatorsEnabled = "kDatabaseKey_TypingIndicatorsEnabled" private let kDatabaseKey_TypingIndicatorsEnabled = "kDatabaseKey_TypingIndicatorsEnabled"
@ -80,7 +86,7 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators {
_areTypingIndicatorsEnabled = value _areTypingIndicatorsEnabled = value
primaryStorage.dbReadWriteConnection.setBool(value, forKey: kDatabaseKey_TypingIndicatorsEnabled, inCollection: kDatabaseCollection) primaryStorage.dbReadWriteConnection.setBool(value, forKey: kDatabaseKey_TypingIndicatorsEnabled, inCollection: kDatabaseCollection)
syncManager.sendConfigurationSyncMessage() syncManager.sendConfigurationSyncMessage()
} }
@ -145,19 +151,35 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators {
} }
@objc @objc
public func areTypingIndicatorsVisible(inThread thread: TSThread, recipientId: String) -> Bool { public func typingRecipientId(forThread thread: TSThread) -> String? {
AssertIsOnMainThread() AssertIsOnMainThread()
let key = incomingIndicatorsKey(forThread: thread, recipientId: recipientId) var firstRecipientId: String?
guard let deviceMap = incomingIndicatorsMap[key] else { var firstTimestamp: UInt64?
return false
let threadKey = incomingIndicatorsKey(forThread: thread)
guard let deviceMap = incomingIndicatorsMap[threadKey] else {
// No devices are typing in this thread.
return nil
} }
for incomingIndicators in deviceMap.values { for incomingIndicators in deviceMap.values {
if incomingIndicators.isTyping { guard incomingIndicators.isTyping else {
return true continue
}
guard let startedTypingTimestamp = incomingIndicators.startedTypingTimestamp else {
owsFailDebug("Typing device is missing start timestamp.")
continue
} }
if let firstTimestamp = firstTimestamp,
firstTimestamp < startedTypingTimestamp {
// More than one recipient/device is typing in this conversation;
// prefer the one that started typing first.
continue
}
firstRecipientId = incomingIndicators.recipientId
firstTimestamp = startedTypingTimestamp
} }
return false return firstRecipientId
} }
// MARK: - // MARK: -
@ -316,27 +338,32 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators {
// MARK: - // MARK: -
// Map of (thread id and recipient id)-to-(device id)-to-IncomingIndicators. // Map of (thread id)-to-(recipient id and device id)-to-IncomingIndicators.
private var incomingIndicatorsMap = [String: [UInt: IncomingIndicators]]() private var incomingIndicatorsMap = [String: [String: IncomingIndicators]]()
private func incomingIndicatorsKey(forThread thread: TSThread, recipientId: String) -> String { private func incomingIndicatorsKey(forThread thread: TSThread) -> String {
return "\(String(describing: thread.uniqueId)) \(recipientId)" return String(describing: thread.uniqueId)
}
private func incomingIndicatorsKey(recipientId: String, deviceId: UInt) -> String {
return "\(recipientId) \(deviceId)"
} }
private func ensureIncomingIndicators(forThread thread: TSThread, recipientId: String, deviceId: UInt) -> IncomingIndicators { private func ensureIncomingIndicators(forThread thread: TSThread, recipientId: String, deviceId: UInt) -> IncomingIndicators {
AssertIsOnMainThread() AssertIsOnMainThread()
let key = incomingIndicatorsKey(forThread: thread, recipientId: recipientId) let threadKey = incomingIndicatorsKey(forThread: thread)
guard let deviceMap = incomingIndicatorsMap[key] else { let deviceKey = incomingIndicatorsKey(recipientId: recipientId, deviceId: deviceId)
let incomingIndicators = IncomingIndicators(delegate: self, recipientId: recipientId, deviceId: deviceId) guard let deviceMap = incomingIndicatorsMap[threadKey] else {
incomingIndicatorsMap[key] = [deviceId: incomingIndicators] let incomingIndicators = IncomingIndicators(delegate: self, thread: thread, recipientId: recipientId, deviceId: deviceId)
incomingIndicatorsMap[threadKey] = [deviceKey: incomingIndicators]
return incomingIndicators return incomingIndicators
} }
guard let incomingIndicators = deviceMap[deviceId] else { guard let incomingIndicators = deviceMap[deviceKey] else {
let incomingIndicators = IncomingIndicators(delegate: self, recipientId: recipientId, deviceId: deviceId) let incomingIndicators = IncomingIndicators(delegate: self, thread: thread, recipientId: recipientId, deviceId: deviceId)
var deviceMapCopy = deviceMap var deviceMapCopy = deviceMap
deviceMapCopy[deviceId] = incomingIndicators deviceMapCopy[deviceKey] = incomingIndicators
incomingIndicatorsMap[key] = deviceMapCopy incomingIndicatorsMap[threadKey] = deviceMapCopy
return incomingIndicators return incomingIndicators
} }
return incomingIndicators return incomingIndicators
@ -345,9 +372,12 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators {
// The receiver maintains one timer for each (sender, device) in a chat: // The receiver maintains one timer for each (sender, device) in a chat:
private class IncomingIndicators { private class IncomingIndicators {
private weak var delegate: TypingIndicators? private weak var delegate: TypingIndicators?
private let recipientId: String private let thread: TSThread
fileprivate let recipientId: String
private let deviceId: UInt private let deviceId: UInt
private var displayTypingTimer: Timer? private var displayTypingTimer: Timer?
fileprivate var startedTypingTimestamp: UInt64?
var isTyping = false { var isTyping = false {
didSet { didSet {
AssertIsOnMainThread() AssertIsOnMainThread()
@ -361,8 +391,10 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators {
} }
} }
init(delegate: TypingIndicators, recipientId: String, deviceId: UInt) { init(delegate: TypingIndicators, thread: TSThread,
recipientId: String, deviceId: UInt) {
self.delegate = delegate self.delegate = delegate
self.thread = thread
self.recipientId = recipientId self.recipientId = recipientId
self.deviceId = deviceId self.deviceId = deviceId
} }
@ -381,43 +413,37 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators {
selector: #selector(IncomingIndicators.displayTypingTimerDidFire), selector: #selector(IncomingIndicators.displayTypingTimerDidFire),
userInfo: nil, userInfo: nil,
repeats: false) repeats: false)
if !isTyping {
startedTypingTimestamp = NSDate.ows_millisecondTimeStamp()
}
isTyping = true isTyping = true
} }
func didReceiveTypingStoppedMessage() { func didReceiveTypingStoppedMessage() {
AssertIsOnMainThread() AssertIsOnMainThread()
// If the client receives a ACTION=STOPPED message: clearTyping()
//
// Cancel the displayTyping timer for that (sender, device)
// Hide the typing indicator for that (sender, device)
displayTypingTimer?.invalidate()
displayTypingTimer = nil
isTyping = false
} }
@objc @objc
func displayTypingTimerDidFire() { func displayTypingTimerDidFire() {
AssertIsOnMainThread() AssertIsOnMainThread()
// If the displayTyping indicator fires: clearTyping()
//
// Cancel the displayTyping timer for that (sender, device)
// Hide the typing indicator for that (sender, device)
displayTypingTimer?.invalidate()
displayTypingTimer = nil
isTyping = false
} }
func didReceiveIncomingMessage() { func didReceiveIncomingMessage() {
AssertIsOnMainThread() AssertIsOnMainThread()
// If the client receives a message: clearTyping()
// }
// Cancel the displayTyping timer for that (sender, device)
// Hide the typing indicator for that (sender, device) private func clearTyping() {
AssertIsOnMainThread()
displayTypingTimer?.invalidate() displayTypingTimer?.invalidate()
displayTypingTimer = nil displayTypingTimer = nil
startedTypingTimestamp = nil
isTyping = false isTyping = false
} }
@ -434,8 +460,11 @@ public class TypingIndicatorsImpl: NSObject, TypingIndicators {
guard delegate.areTypingIndicatorsEnabled() else { guard delegate.areTypingIndicatorsEnabled() else {
return return
} }
guard let threadId = thread.uniqueId else {
NotificationCenter.default.postNotificationNameAsync(TypingIndicatorsImpl.typingIndicatorStateDidChange, object: recipientId) owsFailDebug("Thread is missing id.")
return
}
NotificationCenter.default.postNotificationNameAsync(TypingIndicatorsImpl.typingIndicatorStateDidChange, object: threadId)
} }
} }
} }

Loading…
Cancel
Save