From a98c82645ca64beacbb0e33e9598e9ab19457c41 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Tue, 30 Oct 2018 19:18:17 -0400 Subject: [PATCH] Start work on typing indicators. * Update proto schema to reflect typing indicators. * Sketch out OWSTypingIndicatorMessage. * Add "online" to the service message params. * Sketch out logic to send typing indicator messages. * Sketch out OWSTypingIndicators class. --- Pods | 2 +- Signal.xcodeproj/project.pbxproj | 6 +- .../ConversationInputTextView.h | 4 + .../ConversationInputTextView.m | 4 + .../ConversationViewController.m | 74 +++- SignalMessaging/environment/AppSetup.m | 5 +- SignalServiceKit/protobuf/SignalService.proto | 14 + .../Messages/Interactions/TSOutgoingMessage.h | 2 + .../Messages/Interactions/TSOutgoingMessage.m | 5 + .../src/Messages/OWSMessageManager.m | 60 +++ .../src/Messages/OWSMessageSender.m | 2 + .../src/Messages/OWSMessageServiceParams.h | 4 +- .../src/Messages/OWSMessageServiceParams.m | 2 + .../src/Messages/TypingIndicatorMessage.swift | 118 ++++++ .../src/Protos/Generated/SSKProto.swift | 171 ++++++++- .../Protos/Generated/SignalService.pb.swift | 142 +++++++ SignalServiceKit/src/SSKEnvironment.h | 5 +- SignalServiceKit/src/SSKEnvironment.m | 4 + SignalServiceKit/src/SignalServiceKit.h | 1 + .../src/TestUtils/MockSSKEnvironment.m | 5 +- .../src/Util}/MessageSender+Promise.swift | 0 .../src/Util/TypingIndicators.swift | 363 ++++++++++++++++++ 22 files changed, 964 insertions(+), 29 deletions(-) create mode 100644 SignalServiceKit/src/Messages/TypingIndicatorMessage.swift rename {Signal/src => SignalServiceKit/src/Util}/MessageSender+Promise.swift (100%) create mode 100644 SignalServiceKit/src/Util/TypingIndicators.swift diff --git a/Pods b/Pods index 1776400ae..5821eb8e9 160000 --- a/Pods +++ b/Pods @@ -1 +1 @@ -Subproject commit 1776400ae37dff8b873845a14a10fefe9c89b675 +Subproject commit 5821eb8e9fd9f76fbdbedd3402892c185e65c48e diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index f5212de65..e859c13e6 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -348,7 +348,6 @@ 455A16DE1F1FEA0000F86704 /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 455A16DC1F1FEA0000F86704 /* MetalKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 455AC69E1F4F8B0300134004 /* ImageCacheTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 455AC69D1F4F8B0300134004 /* ImageCacheTest.swift */; }; 45638BDC1F3DD0D400128435 /* DebugUICalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45638BDB1F3DD0D400128435 /* DebugUICalling.swift */; }; - 45638BDF1F3DDB2200128435 /* MessageSender+Promise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45638BDE1F3DDB2200128435 /* MessageSender+Promise.swift */; }; 45666F581D9B2880008FE134 /* OWSScrubbingLogFormatterTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 45666F571D9B2880008FE134 /* OWSScrubbingLogFormatterTest.m */; }; 456F6E2F1E261D1000FD2210 /* PeerConnectionClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 456F6E2E1E261D1000FD2210 /* PeerConnectionClientTest.swift */; }; 4574A5D61DD6704700C6B692 /* CallService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4574A5D51DD6704700C6B692 /* CallService.swift */; }; @@ -1017,7 +1016,6 @@ 455A16DC1F1FEA0000F86704 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; 455AC69D1F4F8B0300134004 /* ImageCacheTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageCacheTest.swift; sourceTree = ""; }; 45638BDB1F3DD0D400128435 /* DebugUICalling.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugUICalling.swift; sourceTree = ""; }; - 45638BDE1F3DDB2200128435 /* MessageSender+Promise.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "MessageSender+Promise.swift"; sourceTree = ""; }; 45666EC41D99483D008FE134 /* OWSAvatarBuilder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSAvatarBuilder.h; sourceTree = ""; }; 45666EC51D99483D008FE134 /* OWSAvatarBuilder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSAvatarBuilder.m; sourceTree = ""; }; 45666EC71D994C0D008FE134 /* OWSGroupAvatarBuilder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSGroupAvatarBuilder.h; sourceTree = ""; }; @@ -2173,6 +2171,7 @@ 76EB04C818170B33006006FC /* util */ = { isa = PBXGroup; children = ( + 4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */, B90418E4183E9DD40038554A /* DateUtil.h */, B90418E5183E9DD40038554A /* DateUtil.m */, 34B0796C1FCF46B000E248C2 /* MainAppContext.h */, @@ -2201,7 +2200,6 @@ 34E5DC8120D8050D00C08145 /* RegistrationUtils.m */, 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */, FCFA64B11A24F29E0007FB87 /* UI Categories */, - 4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */, ); path = util; sourceTree = ""; @@ -2465,7 +2463,6 @@ FCFA64B11A24F29E0007FB87 /* UI Categories */ = { isa = PBXGroup; children = ( - 45638BDE1F3DDB2200128435 /* MessageSender+Promise.swift */, 45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */, 45C0DC1D1E69011F00E04C47 /* UIStoryboard+OWS.swift */, EF764C331DB67CC5000D9A87 /* UIViewController+Permissions.h */, @@ -3395,7 +3392,6 @@ 45AE48511E0732D6004D96C2 /* TurnServerInfo.swift in Sources */, 34B3F8771E8DF1700035BE1A /* ContactsPicker.swift in Sources */, 45C0DC1B1E68FE9000E04C47 /* UIApplication+OWS.swift in Sources */, - 45638BDF1F3DDB2200128435 /* MessageSender+Promise.swift in Sources */, 45FBC5C81DF8575700E9B410 /* CallKitCallManager.swift in Sources */, 4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */, 45FBC5D11DF8592E00E9B410 /* SignalCall.swift in Sources */, diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputTextView.h b/Signal/src/ViewControllers/ConversationView/ConversationInputTextView.h index ee8fdbd10..2c3c561d2 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputTextView.h +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputTextView.h @@ -14,6 +14,10 @@ NS_ASSUME_NONNULL_BEGIN - (void)inputTextViewSendMessagePressed; +- (void)inputTextViewDidChangeText; + +- (void)textViewDidChange:(UITextView *)textView; + @end #pragma mark - diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputTextView.m b/Signal/src/ViewControllers/ConversationView/ConversationInputTextView.m index d9e03324c..233a01ca8 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputTextView.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputTextView.m @@ -65,6 +65,8 @@ NS_ASSUME_NONNULL_BEGIN return self; } +#pragma mark - + - (void)setFont:(UIFont *_Nullable)font { [super setFont:font]; @@ -176,10 +178,12 @@ NS_ASSUME_NONNULL_BEGIN - (void)textViewDidChange:(UITextView *)textView { + OWSAssertDebug(self.inputTextViewDelegate); OWSAssertDebug(self.textViewToolbarDelegate); [self updatePlaceholderVisibility]; + [self.inputTextViewDelegate textViewDidChange:self]; [self.textViewToolbarDelegate textViewDidChange:self]; } diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 00fabd9ee..8b638444b 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -76,6 +76,7 @@ #import #import #import +#import #import #import #import @@ -199,13 +200,6 @@ typedef enum : NSUInteger { @property (nonatomic) BOOL peek; -@property (nonatomic, readonly) OWSContactsManager *contactsManager; -@property (nonatomic, readonly) ContactsUpdater *contactsUpdater; -@property (nonatomic, readonly) OWSMessageSender *messageSender; -@property (nonatomic, readonly) OWSPrimaryStorage *primaryStorage; -@property (nonatomic, readonly) TSNetworkManager *networkManager; -@property (nonatomic, readonly) OutboundCallInitiator *outboundCallInitiator; -@property (nonatomic, readonly) OWSBlockingManager *blockingManager; @property (nonatomic, readonly) ContactsViewHelper *contactsViewHelper; @property (nonatomic) BOOL userHasScrolled; @@ -274,13 +268,6 @@ typedef enum : NSUInteger { - (void)commonInit { _viewControllerCreatedAt = CACurrentMediaTime(); - _contactsManager = Environment.shared.contactsManager; - _contactsUpdater = SSKEnvironment.shared.contactsUpdater; - _messageSender = SSKEnvironment.shared.messageSender; - _outboundCallInitiator = AppEnvironment.shared.outboundCallInitiator; - _primaryStorage = [OWSPrimaryStorage sharedManager]; - _networkManager = [TSNetworkManager sharedManager]; - _blockingManager = [OWSBlockingManager sharedManager]; _contactsViewHelper = [[ContactsViewHelper alloc] initWithDelegate:self]; _contactShareViewHelper = [[ContactShareViewHelper alloc] initWithContactsManager:self.contactsManager]; _contactShareViewHelper.delegate = self; @@ -306,7 +293,47 @@ typedef enum : NSUInteger { return Environment.shared.audioSession; } -#pragma mark +- (OWSMessageSender *)messageSender +{ + return SSKEnvironment.shared.messageSender; +} + +- (OWSContactsManager *)contactsManager +{ + return Environment.shared.contactsManager; +} + +- (ContactsUpdater *)contactsUpdater +{ + return SSKEnvironment.shared.contactsUpdater; +} + +- (OWSBlockingManager *)blockingManager +{ + return [OWSBlockingManager sharedManager]; +} + +- (OWSPrimaryStorage *)primaryStorage +{ + return SSKEnvironment.shared.primaryStorage; +} + +- (TSNetworkManager *)networkManager +{ + return SSKEnvironment.shared.networkManager; +} + +- (OutboundCallInitiator *)outboundCallInitiator +{ + return AppEnvironment.shared.outboundCallInitiator; +} + +- (id)typingIndicators +{ + return SSKEnvironment.shared.typingIndicators; +} + +#pragma mark - - (void)addNotificationListeners { @@ -1108,7 +1135,7 @@ typedef enum : NSUInteger { TSGroupThread *groupThread = (TSGroupThread *)self.thread; int blockedMemberCount = 0; - NSArray *blockedPhoneNumbers = [_blockingManager blockedPhoneNumbers]; + NSArray *blockedPhoneNumbers = [self.blockingManager blockedPhoneNumbers]; for (NSString *contactIdentifier in groupThread.groupModel.groupMemberIds) { if ([blockedPhoneNumbers containsObject:contactIdentifier]) { blockedMemberCount++; @@ -2813,6 +2840,7 @@ typedef enum : NSUInteger { SystemSoundID soundId = [OWSSounds systemSoundIDForSound:OWSSound_MessageSent quiet:YES]; AudioServicesPlaySystemSound(soundId); } + [self.typingIndicators didSendOutgoingMessageInThread:self.thread]; } #pragma mark UIDocumentMenuDelegate @@ -2885,7 +2913,7 @@ typedef enum : NSUInteger { [dataSource setSourceFilename:filename]; - // Although we want to be able to send higher quality attachments throught the document picker + // Although we want to be able to send higher quality attachments through the document picker // it's more imporant that we ensure the sent format is one all clients can accept (e.g. *not* quicktime .mov) if ([SignalAttachment isInvalidVideoWithDataSource:dataSource dataUTI:type]) { [self sendQualityAdjustedAttachmentForVideo:url filename:filename skipApprovalDialog:NO]; @@ -3745,6 +3773,7 @@ typedef enum : NSUInteger { [self stopRecording]; self.audioRecorder = nil; self.voiceMessageUUID = nil; + [self.typingIndicators didStopTypeOutgoingInputInThread:self.thread]; } - (void)setAudioRecorder:(nullable AVAudioRecorder *)audioRecorder @@ -3854,6 +3883,7 @@ typedef enum : NSUInteger { [self dismissKeyBoard]; [self presentViewController:actionSheetController animated:true completion:nil]; + [self.typingIndicators didStartTypeOutgoingInputInThread:self.thread]; } - (nullable NSIndexPath *)lastVisibleIndexPath @@ -4105,6 +4135,15 @@ typedef enum : NSUInteger { #pragma mark - ConversationInputTextViewDelegate +- (void)textViewDidChange:(UITextView *)textView +{ + if (textView.text.length > 0) { + [self.typingIndicators didStartTypeOutgoingInputInThread:self.thread]; + } else { + [self.typingIndicators didStopTypeOutgoingInputInThread:self.thread]; + } +} + - (void)inputTextViewSendMessagePressed { [self sendButtonPressed]; @@ -4521,6 +4560,7 @@ typedef enum : NSUInteger { [self.inputToolbar showVoiceMemoUI]; AudioServicesPlaySystemSound(kSystemSoundID_Vibrate); [self requestRecordingVoiceMemo]; + [self.typingIndicators didStartTypeOutgoingInputInThread:self.thread]; } - (void)voiceMemoGestureDidEnd diff --git a/SignalMessaging/environment/AppSetup.m b/SignalMessaging/environment/AppSetup.m index 6f24f0799..02abb1d79 100644 --- a/SignalMessaging/environment/AppSetup.m +++ b/SignalMessaging/environment/AppSetup.m @@ -85,6 +85,8 @@ NS_ASSUME_NONNULL_BEGIN [[OWSOutgoingReceiptManager alloc] initWithPrimaryStorage:primaryStorage]; OWSSyncManager *syncManager = [[OWSSyncManager alloc] initDefault]; id reachabilityManager = [SSKReachabilityManagerImpl new]; + id typingIndicators = [[OWSTypingIndicatorsImpl alloc] init]; + OWSAudioSession *audioSession = [OWSAudioSession new]; OWSSounds *sounds = [[OWSSounds alloc] initWithPrimaryStorage:primaryStorage]; id proximityMonitoringManager = [OWSProximityMonitoringManagerImpl new]; @@ -120,7 +122,8 @@ NS_ASSUME_NONNULL_BEGIN readReceiptManager:readReceiptManager outgoingReceiptManager:outgoingReceiptManager reachabilityManager:reachabilityManager - syncManager:syncManager]]; + syncManager:syncManager + typingIndicators:typingIndicators]]; appSpecificSingletonBlock(); diff --git a/SignalServiceKit/protobuf/SignalService.proto b/SignalServiceKit/protobuf/SignalService.proto index 71d40c2b2..69109f4b7 100644 --- a/SignalServiceKit/protobuf/SignalService.proto +++ b/SignalServiceKit/protobuf/SignalService.proto @@ -39,12 +39,26 @@ message Envelope { optional uint64 serverTimestamp = 10; } +message TypingMessage { + enum Action { + STARTED = 0; + STOPPED = 1; + } + + // @required + optional uint64 timestamp = 1; + // @required + optional Action action = 2; + optional bytes groupId = 3; +} + message Content { optional DataMessage dataMessage = 1; optional SyncMessage syncMessage = 2; optional CallMessage callMessage = 3; optional NullMessage nullMessage = 4; optional ReceiptMessage receiptMessage = 5; + optional TypingMessage typingMessage = 6; } message CallMessage { diff --git a/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.h b/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.h index d6a131b96..cdd8e3f64 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.h +++ b/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.h @@ -130,6 +130,8 @@ typedef NS_ENUM(NSInteger, TSGroupMetaMessage) { @property (nonatomic, readonly) BOOL isSilent; +@property (nonatomic, readonly) BOOL isOnline; + /** * The data representation of this message, to be encrypted, before being sent. */ diff --git a/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.m b/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.m index e2e515a0f..49a245129 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.m +++ b/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.m @@ -495,6 +495,11 @@ NSString *NSStringForOutgoingMessageRecipientState(OWSOutgoingMessageRecipientSt return NO; } +- (BOOL)isOnline +{ + return NO; +} + - (OWSInteractionType)interactionType { return OWSInteractionType_OutgoingMessage; diff --git a/SignalServiceKit/src/Messages/OWSMessageManager.m b/SignalServiceKit/src/Messages/OWSMessageManager.m index a4be48db2..912b8d842 100644 --- a/SignalServiceKit/src/Messages/OWSMessageManager.m +++ b/SignalServiceKit/src/Messages/OWSMessageManager.m @@ -159,6 +159,11 @@ NS_ASSUME_NONNULL_BEGIN return SSKEnvironment.shared.profileManager; } +- (id)typingIndicators +{ + return SSKEnvironment.shared.typingIndicators; +} + #pragma mark - - (void)startObserving @@ -407,6 +412,8 @@ NS_ASSUME_NONNULL_BEGIN [self handleIncomingEnvelope:envelope withDataMessage:contentProto.dataMessage transaction:transaction]; } else if (contentProto.callMessage) { [self handleIncomingEnvelope:envelope withCallMessage:contentProto.callMessage]; + } else if (contentProto.typingMessage) { + [self handleIncomingEnvelope:envelope withTypingMessage:contentProto.typingMessage transaction:transaction]; } else if (contentProto.nullMessage) { OWSLogInfo(@"Received null message."); } else if (contentProto.receiptMessage) { @@ -642,6 +649,53 @@ NS_ASSUME_NONNULL_BEGIN }); } +- (void)handleIncomingEnvelope:(SSKProtoEnvelope *)envelope + withTypingMessage:(SSKProtoTypingMessage *)typingMessage + transaction:(YapDatabaseReadWriteTransaction *)transaction +{ + OWSAssertDebug(transaction); + + if (!envelope) { + OWSFailDebug(@"Missing envelope."); + return; + } + if (!typingMessage) { + OWSFailDebug(@"Missing typingMessage."); + return; + } + + TSThread *_Nullable thread; + if (typingMessage.hasGroupID) { + thread = [TSGroupThread threadWithGroupId:typingMessage.groupID transaction:transaction]; + } else { + thread = [TSContactThread getThreadWithContactId:envelope.source transaction:transaction]; + } + if (!thread) { + // This isn't neccesarily an error. We might not yet know about the thread, + // in which case we don't need to display the typing indicators. + OWSLogWarn(@"Could not locate thread for typingMessage."); + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + switch (typingMessage.action) { + case SSKProtoTypingMessageActionStarted: + [self.typingIndicators didReceiveTypingStartedMessageInThread:thread + recipientId:envelope.source + deviceId:envelope.sourceDevice]; + break; + case SSKProtoTypingMessageActionStopped: + [self.typingIndicators didReceiveTypingStoppedMessageInThread:thread + recipientId:envelope.source + deviceId:envelope.sourceDevice]; + break; + default: + OWSFailDebug(@"Typing message has unexpected action."); + break; + } + }); +} + - (void)handleReceivedGroupAvatarUpdateWithEnvelope:(SSKProtoEnvelope *)envelope dataMessage:(SSKProtoDataMessage *)dataMessage transaction:(YapDatabaseReadWriteTransaction *)transaction @@ -1352,6 +1406,12 @@ NS_ASSUME_NONNULL_BEGIN inThread:thread contactsManager:self.contactsManager transaction:transaction]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [self.typingIndicators didReceiveIncomingMessageInThread:thread + recipientId:envelope.source + deviceId:envelope.sourceDevice]; + }); } #pragma mark - helpers diff --git a/SignalServiceKit/src/Messages/OWSMessageSender.m b/SignalServiceKit/src/Messages/OWSMessageSender.m index 254dfbbac..56928abb8 100644 --- a/SignalServiceKit/src/Messages/OWSMessageSender.m +++ b/SignalServiceKit/src/Messages/OWSMessageSender.m @@ -1648,12 +1648,14 @@ NSString *const OWSMessageSenderRateLimitedException = @"RateLimitedException"; } BOOL isSilent = message.isSilent; + BOOL isOnline = message.isOnline; OWSMessageServiceParams *messageParams = [[OWSMessageServiceParams alloc] initWithType:messageType recipientId:recipientId device:[deviceId intValue] content:serializedMessage isSilent:isSilent + isOnline:isOnline registrationId:[cipher throws_remoteRegistrationId:transaction]]; NSError *error; diff --git a/SignalServiceKit/src/Messages/OWSMessageServiceParams.h b/SignalServiceKit/src/Messages/OWSMessageServiceParams.h index f32a0e4cb..99f719097 100644 --- a/SignalServiceKit/src/Messages/OWSMessageServiceParams.h +++ b/SignalServiceKit/src/Messages/OWSMessageServiceParams.h @@ -1,5 +1,5 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // #import "TSConstants.h" @@ -20,12 +20,14 @@ @property (nonatomic, readonly) int destinationRegistrationId; @property (nonatomic, readonly) NSString *content; @property (nonatomic, readonly) BOOL silent; +@property (nonatomic, readonly) BOOL online; - (instancetype)initWithType:(TSWhisperMessageType)type recipientId:(NSString *)destination device:(int)deviceId content:(NSData *)content isSilent:(BOOL)isSilent + isOnline:(BOOL)isOnline registrationId:(int)registrationId; @end diff --git a/SignalServiceKit/src/Messages/OWSMessageServiceParams.m b/SignalServiceKit/src/Messages/OWSMessageServiceParams.m index b9653f50f..8cd539dea 100644 --- a/SignalServiceKit/src/Messages/OWSMessageServiceParams.m +++ b/SignalServiceKit/src/Messages/OWSMessageServiceParams.m @@ -18,6 +18,7 @@ device:(int)deviceId content:(NSData *)content isSilent:(BOOL)isSilent + isOnline:(BOOL)isOnline registrationId:(int)registrationId { self = [super init]; @@ -32,6 +33,7 @@ _destinationRegistrationId = registrationId; _content = [content base64EncodedString]; _silent = isSilent; + _online = isOnline; return self; } diff --git a/SignalServiceKit/src/Messages/TypingIndicatorMessage.swift b/SignalServiceKit/src/Messages/TypingIndicatorMessage.swift new file mode 100644 index 000000000..565e907d7 --- /dev/null +++ b/SignalServiceKit/src/Messages/TypingIndicatorMessage.swift @@ -0,0 +1,118 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc(OWSTypingIndicatorAction) +public enum TypingIndicatorAction: Int { + case started + case stopped +} + +@objc(OWSTypingIndicatorMessage) +public class TypingIndicatorMessage: TSOutgoingMessage { + private let action: TypingIndicatorAction + + // MARK: Initializers + + @objc + public init(thread: TSThread, + action: TypingIndicatorAction) { + self.action = action + + super.init(outgoingMessageWithTimestamp: NSDate.ows_millisecondTimeStamp(), + in: thread, + messageBody: nil, + attachmentIds: NSMutableArray(), + expiresInSeconds: 0, + expireStartedAt: 0, + isVoiceMessage: false, + groupMetaMessage: .unspecified, + quotedMessage: nil, + contactShare: nil) + } + + @objc + public required init!(coder: NSCoder) { + self.action = .started + super.init(coder: coder) + } + + @objc + public required init(dictionary dictionaryValue: [AnyHashable: Any]!) throws { + self.action = .started + try super.init(dictionary: dictionaryValue) + } + + @objc + public override func shouldSyncTranscript() -> Bool { + return false + } + + @objc + public override var isSilent: Bool { + return true + } + + @objc + public override var isOnline: Bool { + return true + } + + private func protoAction(forAction action: TypingIndicatorAction) -> SSKProtoTypingMessage.SSKProtoTypingMessageAction { + switch action { + case .started: + return .started + case .stopped: + return .stopped + } + } + + @objc + public override func buildPlainTextData(_ recipient: SignalRecipient) -> Data? { + + let typingBuilder = SSKProtoTypingMessage.builder(timestamp: self.timestamp, + action: protoAction(forAction: action)) + + if let groupThread = self.thread as? TSGroupThread { + typingBuilder.setGroupID(groupThread.groupModel.groupId) + } + + let contentBuilder = SSKProtoContent.builder() + + do { + contentBuilder.setTypingMessage(try typingBuilder.build()) + + let data = try contentBuilder.buildSerializedData() + return data + } catch let error { + owsFailDebug("failed to build content: \(error)") + return nil + } + } + + // MARK: TSYapDatabaseObject overrides + + @objc + public override func shouldBeSaved() -> Bool { + return false + } + + @objc + public override var debugDescription: String { + return "typingIndicatorMessage" + } + + // MARK: + + @objc(stringForTypingIndicatorAction:) + public class func string(forTypingIndicatorAction action: TypingIndicatorAction) -> String { + switch action { + case .started: + return "started" + case .stopped: + return "stopped" + } + } +} diff --git a/SignalServiceKit/src/Protos/Generated/SSKProto.swift b/SignalServiceKit/src/Protos/Generated/SSKProto.swift index a38249dab..ea3c9f89e 100644 --- a/SignalServiceKit/src/Protos/Generated/SSKProto.swift +++ b/SignalServiceKit/src/Protos/Generated/SSKProto.swift @@ -268,6 +268,156 @@ extension SSKProtoEnvelope.SSKProtoEnvelopeBuilder { #endif +// MARK: - SSKProtoTypingMessage + +@objc public class SSKProtoTypingMessage: NSObject { + + // MARK: - SSKProtoTypingMessageAction + + @objc public enum SSKProtoTypingMessageAction: Int32 { + case started = 0 + case stopped = 1 + } + + private class func SSKProtoTypingMessageActionWrap(_ value: SignalServiceProtos_TypingMessage.Action) -> SSKProtoTypingMessageAction { + switch value { + case .started: return .started + case .stopped: return .stopped + } + } + + private class func SSKProtoTypingMessageActionUnwrap(_ value: SSKProtoTypingMessageAction) -> SignalServiceProtos_TypingMessage.Action { + switch value { + case .started: return .started + case .stopped: return .stopped + } + } + + // MARK: - SSKProtoTypingMessageBuilder + + @objc public class func builder(timestamp: UInt64, action: SSKProtoTypingMessageAction) -> SSKProtoTypingMessageBuilder { + return SSKProtoTypingMessageBuilder(timestamp: timestamp, action: action) + } + + // asBuilder() constructs a builder that reflects the proto's contents. + @objc public func asBuilder() -> SSKProtoTypingMessageBuilder { + let builder = SSKProtoTypingMessageBuilder(timestamp: timestamp, action: action) + if let _value = groupID { + builder.setGroupID(_value) + } + return builder + } + + @objc public class SSKProtoTypingMessageBuilder: NSObject { + + private var proto = SignalServiceProtos_TypingMessage() + + @objc fileprivate override init() {} + + @objc fileprivate init(timestamp: UInt64, action: SSKProtoTypingMessageAction) { + super.init() + + setTimestamp(timestamp) + setAction(action) + } + + @objc public func setTimestamp(_ valueParam: UInt64) { + proto.timestamp = valueParam + } + + @objc public func setAction(_ valueParam: SSKProtoTypingMessageAction) { + proto.action = SSKProtoTypingMessageActionUnwrap(valueParam) + } + + @objc public func setGroupID(_ valueParam: Data) { + proto.groupID = valueParam + } + + @objc public func build() throws -> SSKProtoTypingMessage { + return try SSKProtoTypingMessage.parseProto(proto) + } + + @objc public func buildSerializedData() throws -> Data { + return try SSKProtoTypingMessage.parseProto(proto).serializedData() + } + } + + fileprivate let proto: SignalServiceProtos_TypingMessage + + @objc public let timestamp: UInt64 + + @objc public let action: SSKProtoTypingMessageAction + + @objc public var groupID: Data? { + guard proto.hasGroupID else { + return nil + } + return proto.groupID + } + @objc public var hasGroupID: Bool { + return proto.hasGroupID + } + + private init(proto: SignalServiceProtos_TypingMessage, + timestamp: UInt64, + action: SSKProtoTypingMessageAction) { + self.proto = proto + self.timestamp = timestamp + self.action = action + } + + @objc + public func serializedData() throws -> Data { + return try self.proto.serializedData() + } + + @objc public class func parseData(_ serializedData: Data) throws -> SSKProtoTypingMessage { + let proto = try SignalServiceProtos_TypingMessage(serializedData: serializedData) + return try parseProto(proto) + } + + fileprivate class func parseProto(_ proto: SignalServiceProtos_TypingMessage) throws -> SSKProtoTypingMessage { + guard proto.hasTimestamp else { + throw SSKProtoError.invalidProtobuf(description: "\(logTag) missing required field: timestamp") + } + let timestamp = proto.timestamp + + guard proto.hasAction else { + throw SSKProtoError.invalidProtobuf(description: "\(logTag) missing required field: action") + } + let action = SSKProtoTypingMessageActionWrap(proto.action) + + // MARK: - Begin Validation Logic for SSKProtoTypingMessage - + + // MARK: - End Validation Logic for SSKProtoTypingMessage - + + let result = SSKProtoTypingMessage(proto: proto, + timestamp: timestamp, + action: action) + return result + } + + @objc public override var debugDescription: String { + return "\(proto)" + } +} + +#if DEBUG + +extension SSKProtoTypingMessage { + @objc public func serializedDataIgnoringErrors() -> Data? { + return try! self.serializedData() + } +} + +extension SSKProtoTypingMessage.SSKProtoTypingMessageBuilder { + @objc public func buildIgnoringErrors() -> SSKProtoTypingMessage? { + return try! self.build() + } +} + +#endif + // MARK: - SSKProtoContent @objc public class SSKProtoContent: NSObject { @@ -296,6 +446,9 @@ extension SSKProtoEnvelope.SSKProtoEnvelopeBuilder { if let _value = receiptMessage { builder.setReceiptMessage(_value) } + if let _value = typingMessage { + builder.setTypingMessage(_value) + } return builder } @@ -325,6 +478,10 @@ extension SSKProtoEnvelope.SSKProtoEnvelopeBuilder { proto.receiptMessage = valueParam.proto } + @objc public func setTypingMessage(_ valueParam: SSKProtoTypingMessage) { + proto.typingMessage = valueParam.proto + } + @objc public func build() throws -> SSKProtoContent { return try SSKProtoContent.parseProto(proto) } @@ -346,18 +503,22 @@ extension SSKProtoEnvelope.SSKProtoEnvelopeBuilder { @objc public let receiptMessage: SSKProtoReceiptMessage? + @objc public let typingMessage: SSKProtoTypingMessage? + private init(proto: SignalServiceProtos_Content, dataMessage: SSKProtoDataMessage?, syncMessage: SSKProtoSyncMessage?, callMessage: SSKProtoCallMessage?, nullMessage: SSKProtoNullMessage?, - receiptMessage: SSKProtoReceiptMessage?) { + receiptMessage: SSKProtoReceiptMessage?, + typingMessage: SSKProtoTypingMessage?) { self.proto = proto self.dataMessage = dataMessage self.syncMessage = syncMessage self.callMessage = callMessage self.nullMessage = nullMessage self.receiptMessage = receiptMessage + self.typingMessage = typingMessage } @objc @@ -396,6 +557,11 @@ extension SSKProtoEnvelope.SSKProtoEnvelopeBuilder { receiptMessage = try SSKProtoReceiptMessage.parseProto(proto.receiptMessage) } + var typingMessage: SSKProtoTypingMessage? = nil + if proto.hasTypingMessage { + typingMessage = try SSKProtoTypingMessage.parseProto(proto.typingMessage) + } + // MARK: - Begin Validation Logic for SSKProtoContent - // MARK: - End Validation Logic for SSKProtoContent - @@ -405,7 +571,8 @@ extension SSKProtoEnvelope.SSKProtoEnvelopeBuilder { syncMessage: syncMessage, callMessage: callMessage, nullMessage: nullMessage, - receiptMessage: receiptMessage) + receiptMessage: receiptMessage, + typingMessage: typingMessage) return result } diff --git a/SignalServiceKit/src/Protos/Generated/SignalService.pb.swift b/SignalServiceKit/src/Protos/Generated/SignalService.pb.swift index 5b2a09236..130377754 100644 --- a/SignalServiceKit/src/Protos/Generated/SignalService.pb.swift +++ b/SignalServiceKit/src/Protos/Generated/SignalService.pb.swift @@ -180,6 +180,83 @@ extension SignalServiceProtos_Envelope.TypeEnum: CaseIterable { #endif // swift(>=4.2) +struct SignalServiceProtos_TypingMessage { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// @required + var timestamp: UInt64 { + get {return _timestamp ?? 0} + set {_timestamp = newValue} + } + /// Returns true if `timestamp` has been explicitly set. + var hasTimestamp: Bool {return self._timestamp != nil} + /// Clears the value of `timestamp`. Subsequent reads from it will return its default value. + mutating func clearTimestamp() {self._timestamp = nil} + + /// @required + var action: SignalServiceProtos_TypingMessage.Action { + get {return _action ?? .started} + set {_action = newValue} + } + /// Returns true if `action` has been explicitly set. + var hasAction: Bool {return self._action != nil} + /// Clears the value of `action`. Subsequent reads from it will return its default value. + mutating func clearAction() {self._action = nil} + + var groupID: Data { + get {return _groupID ?? SwiftProtobuf.Internal.emptyData} + set {_groupID = newValue} + } + /// Returns true if `groupID` has been explicitly set. + var hasGroupID: Bool {return self._groupID != nil} + /// Clears the value of `groupID`. Subsequent reads from it will return its default value. + mutating func clearGroupID() {self._groupID = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + enum Action: SwiftProtobuf.Enum { + typealias RawValue = Int + case started // = 0 + case stopped // = 1 + + init() { + self = .started + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .started + case 1: self = .stopped + default: return nil + } + } + + var rawValue: Int { + switch self { + case .started: return 0 + case .stopped: return 1 + } + } + + } + + init() {} + + fileprivate var _timestamp: UInt64? = nil + fileprivate var _action: SignalServiceProtos_TypingMessage.Action? = nil + fileprivate var _groupID: Data? = nil +} + +#if swift(>=4.2) + +extension SignalServiceProtos_TypingMessage.Action: CaseIterable { + // Support synthesized by the compiler. +} + +#endif // swift(>=4.2) + struct SignalServiceProtos_Content { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -230,6 +307,15 @@ struct SignalServiceProtos_Content { /// Clears the value of `receiptMessage`. Subsequent reads from it will return its default value. mutating func clearReceiptMessage() {_uniqueStorage()._receiptMessage = nil} + var typingMessage: SignalServiceProtos_TypingMessage { + get {return _storage._typingMessage ?? SignalServiceProtos_TypingMessage()} + set {_uniqueStorage()._typingMessage = newValue} + } + /// Returns true if `typingMessage` has been explicitly set. + var hasTypingMessage: Bool {return _storage._typingMessage != nil} + /// Clears the value of `typingMessage`. Subsequent reads from it will return its default value. + mutating func clearTypingMessage() {_uniqueStorage()._typingMessage = nil} + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} @@ -2229,6 +2315,54 @@ extension SignalServiceProtos_Envelope.TypeEnum: SwiftProtobuf._ProtoNameProvidi ] } +extension SignalServiceProtos_TypingMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".TypingMessage" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "timestamp"), + 2: .same(proto: "action"), + 3: .same(proto: "groupId"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularUInt64Field(value: &self._timestamp) + case 2: try decoder.decodeSingularEnumField(value: &self._action) + case 3: try decoder.decodeSingularBytesField(value: &self._groupID) + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if let v = self._timestamp { + try visitor.visitSingularUInt64Field(value: v, fieldNumber: 1) + } + if let v = self._action { + try visitor.visitSingularEnumField(value: v, fieldNumber: 2) + } + if let v = self._groupID { + try visitor.visitSingularBytesField(value: v, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: SignalServiceProtos_TypingMessage, rhs: SignalServiceProtos_TypingMessage) -> Bool { + if lhs._timestamp != rhs._timestamp {return false} + if lhs._action != rhs._action {return false} + if lhs._groupID != rhs._groupID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension SignalServiceProtos_TypingMessage.Action: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "STARTED"), + 1: .same(proto: "STOPPED"), + ] +} + extension SignalServiceProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".Content" static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ @@ -2237,6 +2371,7 @@ extension SignalServiceProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._Mes 3: .same(proto: "callMessage"), 4: .same(proto: "nullMessage"), 5: .same(proto: "receiptMessage"), + 6: .same(proto: "typingMessage"), ] fileprivate class _StorageClass { @@ -2245,6 +2380,7 @@ extension SignalServiceProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._Mes var _callMessage: SignalServiceProtos_CallMessage? = nil var _nullMessage: SignalServiceProtos_NullMessage? = nil var _receiptMessage: SignalServiceProtos_ReceiptMessage? = nil + var _typingMessage: SignalServiceProtos_TypingMessage? = nil static let defaultInstance = _StorageClass() @@ -2256,6 +2392,7 @@ extension SignalServiceProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._Mes _callMessage = source._callMessage _nullMessage = source._nullMessage _receiptMessage = source._receiptMessage + _typingMessage = source._typingMessage } } @@ -2276,6 +2413,7 @@ extension SignalServiceProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._Mes case 3: try decoder.decodeSingularMessageField(value: &_storage._callMessage) case 4: try decoder.decodeSingularMessageField(value: &_storage._nullMessage) case 5: try decoder.decodeSingularMessageField(value: &_storage._receiptMessage) + case 6: try decoder.decodeSingularMessageField(value: &_storage._typingMessage) default: break } } @@ -2299,6 +2437,9 @@ extension SignalServiceProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._Mes if let v = _storage._receiptMessage { try visitor.visitSingularMessageField(value: v, fieldNumber: 5) } + if let v = _storage._typingMessage { + try visitor.visitSingularMessageField(value: v, fieldNumber: 6) + } } try unknownFields.traverse(visitor: &visitor) } @@ -2313,6 +2454,7 @@ extension SignalServiceProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._Mes if _storage._callMessage != rhs_storage._callMessage {return false} if _storage._nullMessage != rhs_storage._nullMessage {return false} if _storage._receiptMessage != rhs_storage._receiptMessage {return false} + if _storage._typingMessage != rhs_storage._typingMessage {return false} return true } if !storagesAreEqual {return false} diff --git a/SignalServiceKit/src/SSKEnvironment.h b/SignalServiceKit/src/SSKEnvironment.h index ada5b1431..b516750cb 100644 --- a/SignalServiceKit/src/SSKEnvironment.h +++ b/SignalServiceKit/src/SSKEnvironment.h @@ -33,6 +33,7 @@ NS_ASSUME_NONNULL_BEGIN @protocol OWSUDManager; @protocol SSKReachabilityManager; @protocol OWSSyncManagerProtocol; +@protocol OWSTypingIndicators; @interface SSKEnvironment : NSObject @@ -58,7 +59,8 @@ NS_ASSUME_NONNULL_BEGIN readReceiptManager:(OWSReadReceiptManager *)readReceiptManager outgoingReceiptManager:(OWSOutgoingReceiptManager *)outgoingReceiptManager reachabilityManager:(id)reachabilityManager - syncManager:(id)syncManager NS_DESIGNATED_INITIALIZER; + syncManager:(id)syncManager + typingIndicators:(id)typingIndicators NS_DESIGNATED_INITIALIZER; - (instancetype)init NS_UNAVAILABLE; @@ -94,6 +96,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) OWSOutgoingReceiptManager *outgoingReceiptManager; @property (nonatomic, readonly) id syncManager; @property (nonatomic, readonly) id reachabilityManager; +@property (nonatomic, readonly) id typingIndicators; // This property is configured after Environment is created. @property (atomic, nullable) id callMessageHandler; diff --git a/SignalServiceKit/src/SSKEnvironment.m b/SignalServiceKit/src/SSKEnvironment.m index 006e09f1e..37de71b65 100644 --- a/SignalServiceKit/src/SSKEnvironment.m +++ b/SignalServiceKit/src/SSKEnvironment.m @@ -34,6 +34,7 @@ static SSKEnvironment *sharedSSKEnvironment; @property (nonatomic) OWSOutgoingReceiptManager *outgoingReceiptManager; @property (nonatomic) id syncManager; @property (nonatomic) id reachabilityManager; +@property (nonatomic) id typingIndicators; @end @@ -71,6 +72,7 @@ static SSKEnvironment *sharedSSKEnvironment; outgoingReceiptManager:(OWSOutgoingReceiptManager *)outgoingReceiptManager reachabilityManager:(id)reachabilityManager syncManager:(id)syncManager + typingIndicators:(id)typingIndicators { self = [super init]; if (!self) { @@ -100,6 +102,7 @@ static SSKEnvironment *sharedSSKEnvironment; OWSAssertDebug(outgoingReceiptManager); OWSAssertDebug(syncManager); OWSAssertDebug(reachabilityManager); + OWSAssertDebug(typingIndicators); _contactsManager = contactsManager; _messageSender = messageSender; @@ -124,6 +127,7 @@ static SSKEnvironment *sharedSSKEnvironment; _outgoingReceiptManager = outgoingReceiptManager; _syncManager = syncManager; _reachabilityManager = reachabilityManager; + _typingIndicators = typingIndicators; return self; } diff --git a/SignalServiceKit/src/SignalServiceKit.h b/SignalServiceKit/src/SignalServiceKit.h index cd8c4bcd1..41ce35955 100644 --- a/SignalServiceKit/src/SignalServiceKit.h +++ b/SignalServiceKit/src/SignalServiceKit.h @@ -8,4 +8,5 @@ #import #import #import +#import #import diff --git a/SignalServiceKit/src/TestUtils/MockSSKEnvironment.m b/SignalServiceKit/src/TestUtils/MockSSKEnvironment.m index 69eb3eafa..957610a72 100644 --- a/SignalServiceKit/src/TestUtils/MockSSKEnvironment.m +++ b/SignalServiceKit/src/TestUtils/MockSSKEnvironment.m @@ -75,6 +75,7 @@ NS_ASSUME_NONNULL_BEGIN [[OWSOutgoingReceiptManager alloc] initWithPrimaryStorage:primaryStorage]; id reachabilityManager = [SSKReachabilityManagerImpl new]; id syncManager = [[OWSMockSyncManager alloc] init]; + id typingIndicators = [[OWSTypingIndicatorsImpl alloc] init]; self = [super initWithContactsManager:contactsManager messageSender:messageSender @@ -98,7 +99,9 @@ NS_ASSUME_NONNULL_BEGIN readReceiptManager:readReceiptManager outgoingReceiptManager:outgoingReceiptManager reachabilityManager:reachabilityManager - syncManager:syncManager]; + syncManager:syncManager + typingIndicators:typingIndicators]; + if (!self) { return nil; } diff --git a/Signal/src/MessageSender+Promise.swift b/SignalServiceKit/src/Util/MessageSender+Promise.swift similarity index 100% rename from Signal/src/MessageSender+Promise.swift rename to SignalServiceKit/src/Util/MessageSender+Promise.swift diff --git a/SignalServiceKit/src/Util/TypingIndicators.swift b/SignalServiceKit/src/Util/TypingIndicators.swift new file mode 100644 index 000000000..a6cf5c2fc --- /dev/null +++ b/SignalServiceKit/src/Util/TypingIndicators.swift @@ -0,0 +1,363 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc(OWSTypingIndicators) +public protocol TypingIndicators: class { + // TODO: Use this method. + @objc + func didStartTypeOutgoingInput(inThread thread: TSThread) + + // TODO: Use this method. + @objc + func didStopTypeOutgoingInput(inThread thread: TSThread) + + @objc + func didSendOutgoingMessage(inThread thread: TSThread) + + @objc + func didReceiveTypingStartedMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) + + @objc + func didReceiveTypingStoppedMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) + + @objc + func didReceiveIncomingMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) + + // TODO: Use this method. + @objc + func areTypingIndicatorsVisible(inThread thread: TSThread, recipientId: String) -> Bool +} + +// MARK: - + +@objc(OWSTypingIndicatorsImpl) +public class TypingIndicatorsImpl: NSObject, TypingIndicators { + @objc public static let typingIndicatorStateDidChange = Notification.Name("typingIndicatorStateDidChange") + + @objc + public func didStartTypeOutgoingInput(inThread thread: TSThread) { + AssertIsOnMainThread() + guard let outgoingIndicators = ensureOutgoingIndicators(forThread: thread) else { + owsFailDebug("Could not locate outgoing indicators state") + return + } + outgoingIndicators.didStartTypeOutgoingInput() + } + + @objc + public func didStopTypeOutgoingInput(inThread thread: TSThread) { + AssertIsOnMainThread() + guard let outgoingIndicators = ensureOutgoingIndicators(forThread: thread) else { + owsFailDebug("Could not locate outgoing indicators state") + return + } + outgoingIndicators.didStopTypeOutgoingInput() + } + + @objc + public func didSendOutgoingMessage(inThread thread: TSThread) { + AssertIsOnMainThread() + guard let outgoingIndicators = ensureOutgoingIndicators(forThread: thread) else { + owsFailDebug("Could not locate outgoing indicators state") + return + } + outgoingIndicators.didSendOutgoingMessage() + } + + @objc + public func didReceiveTypingStartedMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) { + AssertIsOnMainThread() + let incomingIndicators = ensureIncomingIndicators(forThread: thread, recipientId: recipientId, deviceId: deviceId) + incomingIndicators.didReceiveTypingStartedMessage() + } + + @objc + public func didReceiveTypingStoppedMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) { + AssertIsOnMainThread() + let incomingIndicators = ensureIncomingIndicators(forThread: thread, recipientId: recipientId, deviceId: deviceId) + incomingIndicators.didReceiveTypingStoppedMessage() + } + + @objc + public func didReceiveIncomingMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) { + AssertIsOnMainThread() + let incomingIndicators = ensureIncomingIndicators(forThread: thread, recipientId: recipientId, deviceId: deviceId) + incomingIndicators.didReceiveIncomingMessage() + } + + @objc + public func areTypingIndicatorsVisible(inThread thread: TSThread, recipientId: String) -> Bool { + AssertIsOnMainThread() + + let key = incomingIndicatorsKey(forThread: thread, recipientId: recipientId) + guard let deviceMap = incomingIndicatorsMap[key] else { + return false + } + for incomingIndicators in deviceMap.values { + if incomingIndicators.isTyping { + return true + } + } + return false + } + + // MARK: - + + // Map of thread id-to-OutgoingIndicators. + private var outgoingIndicatorsMap = [String: OutgoingIndicators]() + + private func ensureOutgoingIndicators(forThread thread: TSThread) -> OutgoingIndicators? { + AssertIsOnMainThread() + + guard let threadId = thread.uniqueId else { + owsFailDebug("Thread missing id") + return nil + } + if let outgoingIndicators = outgoingIndicatorsMap[threadId] { + return outgoingIndicators + } + let outgoingIndicators = OutgoingIndicators(thread: thread) + outgoingIndicatorsMap[threadId] = outgoingIndicators + return outgoingIndicators + } + + // The sender maintains two timers per chat: + // + // A sendPause timer + // A sendRefresh timer + private class OutgoingIndicators { + private let thread: TSThread + private var sendPauseTimer: Timer? + private var sendRefreshTimer: Timer? + + init(thread: TSThread) { + self.thread = thread + } + + // MARK: - Dependencies + + private var messageSender: MessageSender { + return SSKEnvironment.shared.messageSender + } + + // MARK: - + + func didStartTypeOutgoingInput() { + AssertIsOnMainThread() + + if sendRefreshTimer == nil { + // If the user types a character into the compose box, and the sendRefresh timer isn’t running: + + // Send a ACTION=TYPING message. + sendTypingMessage(forThread: thread, action: .started) + // Start the sendRefresh timer for 10 seconds + sendRefreshTimer?.invalidate() + sendRefreshTimer = Timer.weakScheduledTimer(withTimeInterval: 10, + target: self, + selector: #selector(OutgoingIndicators.sendRefreshTimerDidFire), + userInfo: nil, + repeats: false) + // Start the sendPause timer for 5 seconds + } else { + // If the user types a character into the compose box, and the sendRefresh timer is running: + + // Send nothing + // Cancel the sendPause timer + // Start the sendPause timer for 5 seconds again + } + + sendPauseTimer?.invalidate() + sendPauseTimer = Timer.weakScheduledTimer(withTimeInterval: 5, + target: self, + selector: #selector(OutgoingIndicators.sendPauseTimerDidFire), + userInfo: nil, + repeats: false) + } + + func didStopTypeOutgoingInput() { + AssertIsOnMainThread() + + // Send ACTION=STOPPED message. + sendTypingMessage(forThread: thread, action: .stopped) + // Cancel the sendRefresh timer + sendRefreshTimer?.invalidate() + sendRefreshTimer = nil + // Cancel the sendPause timer + sendPauseTimer?.invalidate() + sendPauseTimer = nil + } + + @objc + func sendPauseTimerDidFire() { + AssertIsOnMainThread() + + // If the sendPause timer fires: + + // Send ACTION=STOPPED message. + sendTypingMessage(forThread: thread, action: .stopped) + // Cancel the sendRefresh timer + sendRefreshTimer?.invalidate() + sendRefreshTimer = nil + // Cancel the sendPause timer + sendPauseTimer?.invalidate() + sendPauseTimer = nil + } + + @objc + func sendRefreshTimerDidFire() { + AssertIsOnMainThread() + + // If the sendRefresh timer fires: + + // Send ACTION=TYPING message + sendTypingMessage(forThread: thread, action: .started) + // Cancel the sendRefresh timer + sendRefreshTimer?.invalidate() + // Start the sendRefresh timer for 10 seconds again + sendRefreshTimer = Timer.weakScheduledTimer(withTimeInterval: 10, + target: self, + selector: #selector(sendRefreshTimerDidFire), + userInfo: nil, + repeats: false) + } + + func didSendOutgoingMessage() { + AssertIsOnMainThread() + + // If the user sends the message: + + // Cancel the sendRefresh timer + sendRefreshTimer?.invalidate() + sendRefreshTimer = nil + // Cancel the sendPause timer + sendPauseTimer?.invalidate() + sendPauseTimer = nil + } + + private func sendTypingMessage(forThread thread: TSThread, action: TypingIndicatorAction) { + Logger.verbose("\(TypingIndicatorMessage.string(forTypingIndicatorAction: action))") + + let message = TypingIndicatorMessage(thread: thread, action: action) + messageSender.sendPromise(message: message) + .done { + Logger.info("Outgoing typing indicator message send succeeded.") + }.catch { error in + Logger.error("Outgoing typing indicator message send failed: \(error).") + }.retainUntilComplete() + } + } + + // MARK: - + + // Map of (thread id and recipient id)-to-(device id)-to-IncomingIndicators. + private var incomingIndicatorsMap = [String: [UInt: IncomingIndicators]]() + + private func incomingIndicatorsKey(forThread thread: TSThread, recipientId: String) -> String { + return "\(String(describing: thread.uniqueId)) \(recipientId)" + } + + private func ensureIncomingIndicators(forThread thread: TSThread, recipientId: String, deviceId: UInt) -> IncomingIndicators { + AssertIsOnMainThread() + + let key = incomingIndicatorsKey(forThread: thread, recipientId: recipientId) + guard let deviceMap = incomingIndicatorsMap[key] else { + let incomingIndicators = IncomingIndicators(recipientId: recipientId, deviceId: deviceId) + incomingIndicatorsMap[key] = [deviceId: incomingIndicators] + return incomingIndicators + } + guard let incomingIndicators = deviceMap[deviceId] else { + let incomingIndicators = IncomingIndicators(recipientId: recipientId, deviceId: deviceId) + var deviceMapCopy = deviceMap + deviceMapCopy[deviceId] = incomingIndicators + incomingIndicatorsMap[key] = deviceMapCopy + return incomingIndicators + } + return incomingIndicators + } + + // The receiver maintains one timer for each (sender, device) in a chat: + private class IncomingIndicators { + private let recipientId: String + private let deviceId: UInt + private var displayTypingTimer: Timer? + var isTyping = false { + didSet { + AssertIsOnMainThread() + + let didChange = oldValue != isTyping + if didChange { + Logger.debug("isTyping changed: \(oldValue) -> \(self.isTyping)") + + notify() + } + } + } + + init(recipientId: String, deviceId: UInt) { + self.recipientId = recipientId + self.deviceId = deviceId + } + + func didReceiveTypingStartedMessage() { + AssertIsOnMainThread() + + // If the client receives a ACTION=TYPING message: + // + // Cancel the displayTyping timer for that (sender, device) + // Display the typing indicator for that (sender, device) + // Set the displayTyping timer for 15 seconds + displayTypingTimer?.invalidate() + displayTypingTimer = Timer.weakScheduledTimer(withTimeInterval: 15, + target: self, + selector: #selector(IncomingIndicators.displayTypingTimerDidFire), + userInfo: nil, + repeats: false) + isTyping = true + } + + func didReceiveTypingStoppedMessage() { + AssertIsOnMainThread() + + // If the client receives a ACTION=STOPPED message: + // + // Cancel the displayTyping timer for that (sender, device) + // Hide the typing indicator for that (sender, device) + displayTypingTimer?.invalidate() + displayTypingTimer = nil + isTyping = false + } + + @objc + func displayTypingTimerDidFire() { + AssertIsOnMainThread() + + // If the displayTyping indicator fires: + // + // Cancel the displayTyping timer for that (sender, device) + // Hide the typing indicator for that (sender, device) + displayTypingTimer?.invalidate() + displayTypingTimer = nil + isTyping = false + } + + func didReceiveIncomingMessage() { + AssertIsOnMainThread() + + // If the client receives a message: + // + // Cancel the displayTyping timer for that (sender, device) + // Hide the typing indicator for that (sender, device) + displayTypingTimer?.invalidate() + displayTypingTimer = nil + isTyping = false + } + + private func notify() { + Logger.verbose("") + NotificationCenter.default.postNotificationNameAsync(TypingIndicatorsImpl.typingIndicatorStateDidChange, object: recipientId) + } + } +}