session-ios/SignalServiceKit/src/Util/TypingIndicators.swift
Matthew Chen a98c82645c 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.
2018-10-31 12:11:29 -04:00

364 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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)
}
}
}