// // 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) // Returns the recipient id of the user who should currently be shown typing for a given thread. // // If no one is typing in that thread, returns nil. // If multiple users are typing in that thread, returns the user to show. // // TODO: Use this method. @objc func typingRecipientId(forThread thread: TSThread) -> String? @objc func setTypingIndicatorsEnabled(value: Bool) @objc func areTypingIndicatorsEnabled() -> Bool } // MARK: - @objc(OWSTypingIndicatorsImpl) public class TypingIndicatorsImpl: NSObject, TypingIndicators { @objc public static let typingIndicatorStateDidChange = Notification.Name("typingIndicatorStateDidChange") private let kDatabaseCollection = "TypingIndicators" private let kDatabaseKey_TypingIndicatorsEnabled = "kDatabaseKey_TypingIndicatorsEnabled" private var _areTypingIndicatorsEnabled = false public override init() { super.init() AppReadiness.runNowOrWhenAppWillBecomeReady { self.setup() } } private func setup() { _areTypingIndicatorsEnabled = OWSPrimaryStorage.shared().dbReadConnection.bool(forKey: kDatabaseKey_TypingIndicatorsEnabled, inCollection: kDatabaseCollection, defaultValue: true) } // MARK: - @objc public func setTypingIndicatorsEnabled(value: Bool) { _areTypingIndicatorsEnabled = value OWSPrimaryStorage.shared().dbReadWriteConnection.setBool(value, forKey: kDatabaseKey_TypingIndicatorsEnabled, inCollection: kDatabaseCollection) NotificationCenter.default.postNotificationNameAsync(TypingIndicatorsImpl.typingIndicatorStateDidChange, object: nil) } @objc public func areTypingIndicatorsEnabled() -> Bool { return _areTypingIndicatorsEnabled } // MARK: - @objc public func didStartTypingOutgoingInput(inThread thread: TSThread) { guard let outgoingIndicators = ensureOutgoingIndicators(forThread: thread) else { return } outgoingIndicators.didStartTypingOutgoingInput() } @objc public func didStopTypingOutgoingInput(inThread thread: TSThread) { guard let outgoingIndicators = ensureOutgoingIndicators(forThread: thread) else { return } outgoingIndicators.didStopTypingOutgoingInput() } @objc public func didSendOutgoingMessage(inThread thread: TSThread) { guard let outgoingIndicators = ensureOutgoingIndicators(forThread: thread) else { return } outgoingIndicators.didSendOutgoingMessage() } @objc public func didReceiveTypingStartedMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) { let incomingIndicators = ensureIncomingIndicators(forThread: thread, recipientId: recipientId, deviceId: deviceId) incomingIndicators.didReceiveTypingStartedMessage() } @objc public func didReceiveTypingStoppedMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) { let incomingIndicators = ensureIncomingIndicators(forThread: thread, recipientId: recipientId, deviceId: deviceId) incomingIndicators.didReceiveTypingStoppedMessage() } @objc public func didReceiveIncomingMessage(inThread thread: TSThread, recipientId: String, deviceId: UInt) { let incomingIndicators = ensureIncomingIndicators(forThread: thread, recipientId: recipientId, deviceId: deviceId) incomingIndicators.didReceiveIncomingMessage() } @objc public func typingRecipientId(forThread thread: TSThread) -> String? { guard areTypingIndicatorsEnabled() else { return nil } var firstRecipientId: String? var firstTimestamp: UInt64? let threadKey = incomingIndicatorsKey(forThread: thread) guard let deviceMap = incomingIndicatorsMap[threadKey] else { // No devices are typing in this thread. return nil } for incomingIndicators in deviceMap.values { guard incomingIndicators.isTyping else { continue } guard let startedTypingTimestamp = incomingIndicators.startedTypingTimestamp else { continue } if let firstTimestamp = firstTimestamp, firstTimestamp < startedTypingTimestamp { // More than one recipient/device is typing in this conversation; // prefer the one that started typing first. continue } firstRecipientId = incomingIndicators.recipientId firstTimestamp = startedTypingTimestamp } return firstRecipientId } // MARK: - // Map of thread id-to-OutgoingIndicators. private var outgoingIndicatorsMap = [String: OutgoingIndicators]() private func ensureOutgoingIndicators(forThread thread: TSThread) -> OutgoingIndicators? { guard let threadId = thread.uniqueId else { return nil } if let outgoingIndicators = outgoingIndicatorsMap[threadId] { return outgoingIndicators } let outgoingIndicators = OutgoingIndicators(delegate: self, thread: thread) outgoingIndicatorsMap[threadId] = outgoingIndicators return outgoingIndicators } // The sender maintains two timers per chat: // // A sendPause timer // A sendRefresh timer private class OutgoingIndicators { private weak var delegate: TypingIndicators? private let thread: TSThread private var sendPauseTimer: Timer? private var sendRefreshTimer: Timer? init(delegate: TypingIndicators, thread: TSThread) { self.delegate = delegate self.thread = thread } // MARK: - func didStartTypingOutgoingInput() { if sendRefreshTimer == nil { // If the user types a character into the compose box, and the sendRefresh timer isn’t running: sendTypingMessageIfNecessary(forThread: thread, action: .started) sendRefreshTimer?.invalidate() sendRefreshTimer = Timer.weakScheduledTimer(withTimeInterval: 10, target: self, selector: #selector(OutgoingIndicators.sendRefreshTimerDidFire), userInfo: nil, repeats: false) } else { // If the user types a character into the compose box, and the sendRefresh timer is running: } sendPauseTimer?.invalidate() sendPauseTimer = Timer.weakScheduledTimer(withTimeInterval: 3, target: self, selector: #selector(OutgoingIndicators.sendPauseTimerDidFire), userInfo: nil, repeats: false) } func didStopTypingOutgoingInput() { sendTypingMessageIfNecessary(forThread: thread, action: .stopped) sendRefreshTimer?.invalidate() sendRefreshTimer = nil sendPauseTimer?.invalidate() sendPauseTimer = nil } @objc func sendPauseTimerDidFire() { sendTypingMessageIfNecessary(forThread: thread, action: .stopped) sendRefreshTimer?.invalidate() sendRefreshTimer = nil sendPauseTimer?.invalidate() sendPauseTimer = nil } @objc func sendRefreshTimerDidFire() { sendTypingMessageIfNecessary(forThread: thread, action: .started) sendRefreshTimer?.invalidate() sendRefreshTimer = Timer.weakScheduledTimer(withTimeInterval: 10, target: self, selector: #selector(sendRefreshTimerDidFire), userInfo: nil, repeats: false) } func didSendOutgoingMessage() { sendRefreshTimer?.invalidate() sendRefreshTimer = nil sendPauseTimer?.invalidate() sendPauseTimer = nil } private func sendTypingMessageIfNecessary(forThread thread: TSThread, action: TypingIndicator.Kind) { guard let delegate = delegate else { return } // `areTypingIndicatorsEnabled` reflects the user-facing setting in the app preferences. // If it's disabled we don't want to emit "typing indicator" messages // or show typing indicators for other users. guard delegate.areTypingIndicatorsEnabled() else { return } if thread.isGroupThread() { return } // Don't send typing indicators in group threads let typingIndicator = TypingIndicator() typingIndicator.kind = action SNMessagingKitConfiguration.shared.storage.write { transaction in MessageSender.send(typingIndicator, in: thread, using: transaction as! YapDatabaseReadWriteTransaction) } } } // MARK: - // Map of (thread id)-to-(recipient id and device id)-to-IncomingIndicators. private var incomingIndicatorsMap = [String: [String: IncomingIndicators]]() private func incomingIndicatorsKey(forThread thread: TSThread) -> String { return String(describing: thread.uniqueId) } private func incomingIndicatorsKey(recipientId: String, deviceId: UInt) -> String { return "\(recipientId) \(deviceId)" } private func ensureIncomingIndicators(forThread thread: TSThread, recipientId: String, deviceId: UInt) -> IncomingIndicators { let threadKey = incomingIndicatorsKey(forThread: thread) let deviceKey = incomingIndicatorsKey(recipientId: recipientId, deviceId: deviceId) guard let deviceMap = incomingIndicatorsMap[threadKey] else { let incomingIndicators = IncomingIndicators(delegate: self, thread: thread, recipientId: recipientId, deviceId: deviceId) incomingIndicatorsMap[threadKey] = [deviceKey: incomingIndicators] return incomingIndicators } guard let incomingIndicators = deviceMap[deviceKey] else { let incomingIndicators = IncomingIndicators(delegate: self, thread: thread, recipientId: recipientId, deviceId: deviceId) var deviceMapCopy = deviceMap deviceMapCopy[deviceKey] = incomingIndicators incomingIndicatorsMap[threadKey] = deviceMapCopy return incomingIndicators } return incomingIndicators } // The receiver maintains one timer for each (sender, device) in a chat: private class IncomingIndicators { private weak var delegate: TypingIndicators? private let thread: TSThread fileprivate let recipientId: String private let deviceId: UInt private var displayTypingTimer: Timer? fileprivate var startedTypingTimestamp: UInt64? var isTyping = false { didSet { let didChange = oldValue != isTyping if didChange { notifyIfNecessary() } } } init(delegate: TypingIndicators, thread: TSThread, recipientId: String, deviceId: UInt) { self.delegate = delegate self.thread = thread self.recipientId = recipientId self.deviceId = deviceId } func didReceiveTypingStartedMessage() { displayTypingTimer?.invalidate() displayTypingTimer = Timer.weakScheduledTimer(withTimeInterval: 5, target: self, selector: #selector(IncomingIndicators.displayTypingTimerDidFire), userInfo: nil, repeats: false) if !isTyping { startedTypingTimestamp = NSDate.ows_millisecondTimeStamp() } isTyping = true } func didReceiveTypingStoppedMessage() { clearTyping() } @objc func displayTypingTimerDidFire() { clearTyping() } func didReceiveIncomingMessage() { clearTyping() } private func clearTyping() { displayTypingTimer?.invalidate() displayTypingTimer = nil startedTypingTimestamp = nil isTyping = false } private func notifyIfNecessary() { guard let delegate = delegate else { return } // `areTypingIndicatorsEnabled` reflects the user-facing setting in the app preferences. // If it's disabled we don't want to emit "typing indicator" messages // or show typing indicators for other users. guard delegate.areTypingIndicatorsEnabled() else { return } guard let threadId = thread.uniqueId else { return } NotificationCenter.default.postNotificationNameAsync(TypingIndicatorsImpl.typingIndicatorStateDidChange, object: threadId) } } }