// // Copyright (c) 2018 Open Whisper Systems. All rights reserved. // import Foundation @objc(OWSTypingIndicators) public protocol TypingIndicators: class { @objc func didStartTypingOutgoingInput(inThread thread: TSThread) @objc func didStopTypingOutgoingInput(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 didStartTypingOutgoingInput(inThread thread: TSThread) { AssertIsOnMainThread() guard let outgoingIndicators = ensureOutgoingIndicators(forThread: thread) else { owsFailDebug("Could not locate outgoing indicators state") return } outgoingIndicators.didStartTypingOutgoingInput() } @objc public func didStopTypingOutgoingInput(inThread thread: TSThread) { AssertIsOnMainThread() guard let outgoingIndicators = ensureOutgoingIndicators(forThread: thread) else { owsFailDebug("Could not locate outgoing indicators state") return } outgoingIndicators.didStopTypingOutgoingInput() } @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 didStartTypingOutgoingInput() { 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 didStopTypingOutgoingInput() { 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).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) } } }