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.
This commit is contained in:
Matthew Chen 2018-10-30 19:18:17 -04:00
parent 40aa78e001
commit a98c82645c
22 changed files with 964 additions and 29 deletions

2
Pods

@ -1 +1 @@
Subproject commit 1776400ae37dff8b873845a14a10fefe9c89b675
Subproject commit 5821eb8e9fd9f76fbdbedd3402892c185e65c48e

View File

@ -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 = "<group>"; };
45638BDB1F3DD0D400128435 /* DebugUICalling.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugUICalling.swift; sourceTree = "<group>"; };
45638BDE1F3DDB2200128435 /* MessageSender+Promise.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "MessageSender+Promise.swift"; sourceTree = "<group>"; };
45666EC41D99483D008FE134 /* OWSAvatarBuilder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSAvatarBuilder.h; sourceTree = "<group>"; };
45666EC51D99483D008FE134 /* OWSAvatarBuilder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSAvatarBuilder.m; sourceTree = "<group>"; };
45666EC71D994C0D008FE134 /* OWSGroupAvatarBuilder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSGroupAvatarBuilder.h; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 */,

View File

@ -14,6 +14,10 @@ NS_ASSUME_NONNULL_BEGIN
- (void)inputTextViewSendMessagePressed;
- (void)inputTextViewDidChangeText;
- (void)textViewDidChange:(UITextView *)textView;
@end
#pragma mark -

View File

@ -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];
}

View File

@ -76,6 +76,7 @@
#import <SignalServiceKit/OWSPrimaryStorage.h>
#import <SignalServiceKit/OWSReadReceiptManager.h>
#import <SignalServiceKit/OWSVerificationStateChangeMessage.h>
#import <SignalServiceKit/SignalServiceKit-Swift.h>
#import <SignalServiceKit/TSAccountManager.h>
#import <SignalServiceKit/TSGroupModel.h>
#import <SignalServiceKit/TSInvalidIdentityKeyReceivingErrorMessage.h>
@ -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<OWSTypingIndicators>)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<NSString *> *blockedPhoneNumbers = [_blockingManager blockedPhoneNumbers];
NSArray<NSString *> *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

View File

@ -85,6 +85,8 @@ NS_ASSUME_NONNULL_BEGIN
[[OWSOutgoingReceiptManager alloc] initWithPrimaryStorage:primaryStorage];
OWSSyncManager *syncManager = [[OWSSyncManager alloc] initDefault];
id<SSKReachabilityManager> reachabilityManager = [SSKReachabilityManagerImpl new];
id<OWSTypingIndicators> typingIndicators = [[OWSTypingIndicatorsImpl alloc] init];
OWSAudioSession *audioSession = [OWSAudioSession new];
OWSSounds *sounds = [[OWSSounds alloc] initWithPrimaryStorage:primaryStorage];
id<OWSProximityMonitoringManager> proximityMonitoringManager = [OWSProximityMonitoringManagerImpl new];
@ -120,7 +122,8 @@ NS_ASSUME_NONNULL_BEGIN
readReceiptManager:readReceiptManager
outgoingReceiptManager:outgoingReceiptManager
reachabilityManager:reachabilityManager
syncManager:syncManager]];
syncManager:syncManager
typingIndicators:typingIndicators]];
appSpecificSingletonBlock();

View File

@ -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 {

View File

@ -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.
*/

View File

@ -495,6 +495,11 @@ NSString *NSStringForOutgoingMessageRecipientState(OWSOutgoingMessageRecipientSt
return NO;
}
- (BOOL)isOnline
{
return NO;
}
- (OWSInteractionType)interactionType
{
return OWSInteractionType_OutgoingMessage;

View File

@ -159,6 +159,11 @@ NS_ASSUME_NONNULL_BEGIN
return SSKEnvironment.shared.profileManager;
}
- (id<OWSTypingIndicators>)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

View File

@ -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;

View File

@ -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

View File

@ -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;
}

View File

@ -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"
}
}
}

View File

@ -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
}

View File

@ -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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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}

View File

@ -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<SSKReachabilityManager>)reachabilityManager
syncManager:(id<OWSSyncManagerProtocol>)syncManager NS_DESIGNATED_INITIALIZER;
syncManager:(id<OWSSyncManagerProtocol>)syncManager
typingIndicators:(id<OWSTypingIndicators>)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<OWSSyncManagerProtocol> syncManager;
@property (nonatomic, readonly) id<SSKReachabilityManager> reachabilityManager;
@property (nonatomic, readonly) id<OWSTypingIndicators> typingIndicators;
// This property is configured after Environment is created.
@property (atomic, nullable) id<OWSCallMessageHandler> callMessageHandler;

View File

@ -34,6 +34,7 @@ static SSKEnvironment *sharedSSKEnvironment;
@property (nonatomic) OWSOutgoingReceiptManager *outgoingReceiptManager;
@property (nonatomic) id<OWSSyncManagerProtocol> syncManager;
@property (nonatomic) id<SSKReachabilityManager> reachabilityManager;
@property (nonatomic) id<OWSTypingIndicators> typingIndicators;
@end
@ -71,6 +72,7 @@ static SSKEnvironment *sharedSSKEnvironment;
outgoingReceiptManager:(OWSOutgoingReceiptManager *)outgoingReceiptManager
reachabilityManager:(id<SSKReachabilityManager>)reachabilityManager
syncManager:(id<OWSSyncManagerProtocol>)syncManager
typingIndicators:(id<OWSTypingIndicators>)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;
}

View File

@ -8,4 +8,5 @@
#import <SignalServiceKit/OWSOperation.h>
#import <SignalServiceKit/OWSSyncManagerProtocol.h>
#import <SignalServiceKit/SSKJobRecord.h>
#import <SignalServiceKit/TSOutgoingMessage.h>
#import <SignalServiceKit/TSYapDatabaseObject.h>

View File

@ -75,6 +75,7 @@ NS_ASSUME_NONNULL_BEGIN
[[OWSOutgoingReceiptManager alloc] initWithPrimaryStorage:primaryStorage];
id<SSKReachabilityManager> reachabilityManager = [SSKReachabilityManagerImpl new];
id<OWSSyncManagerProtocol> syncManager = [[OWSMockSyncManager alloc] init];
id<OWSTypingIndicators> 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;
}

View File

@ -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 isnt 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)
}
}
}