session-ios/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift

228 lines
8.3 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
import SessionSnodeKit
public class TypingIndicators {
// MARK: - Direction
public enum Direction {
case outgoing
case incoming
}
private class Indicator {
fileprivate let threadId: String
fileprivate let threadVariant: SessionThread.Variant
fileprivate let direction: Direction
fileprivate let timestampMs: Int64
fileprivate var refreshTimer: Timer?
fileprivate var stopTimer: Timer?
init?(
threadId: String,
threadVariant: SessionThread.Variant,
threadIsBlocked: Bool,
threadIsMessageRequest: Bool,
direction: Direction,
timestampMs: Int64?
) {
// The `typingIndicatorsEnabled` flag 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
//
// We also don't want to show/send typing indicators for message requests
guard
Storage.shared[.typingIndicatorsEnabled] &&
!threadIsBlocked &&
!threadIsMessageRequest
else { return nil }
// Don't send typing indicators in group threads
guard
threadVariant != .legacyGroup &&
threadVariant != .group &&
threadVariant != .community
else { return nil }
self.threadId = threadId
self.threadVariant = threadVariant
self.direction = direction
self.timestampMs = (timestampMs ?? SnodeAPI.currentOffsetTimestampMs())
}
fileprivate func start(_ db: Database, using dependencies: Dependencies = Dependencies()) {
// Start the typing indicator
switch direction {
case .outgoing:
scheduleRefreshCallback(db, shouldSend: (refreshTimer == nil), using: dependencies)
case .incoming:
try? ThreadTypingIndicator(
threadId: threadId,
timestampMs: timestampMs
)
.save(db)
}
// Refresh the timeout since we just started
refreshTimeout()
}
fileprivate func stop(_ db: Database, using dependencies: Dependencies = Dependencies()) {
self.refreshTimer?.invalidate()
self.refreshTimer = nil
self.stopTimer?.invalidate()
self.stopTimer = nil
switch direction {
case .outgoing:
try? MessageSender.send(
db,
message: TypingIndicator(kind: .stopped),
interactionId: nil,
threadId: threadId,
threadVariant: threadVariant,
using: dependencies
)
case .incoming:
_ = try? ThreadTypingIndicator
.filter(ThreadTypingIndicator.Columns.threadId == self.threadId)
.deleteAll(db)
}
}
fileprivate func refreshTimeout() {
let threadId: String = self.threadId
let direction: Direction = self.direction
// Schedule the 'stopCallback' to cancel the typing indicator
stopTimer?.invalidate()
stopTimer = Timer.scheduledTimerOnMainThread(
withTimeInterval: (direction == .outgoing ? 3 : 5),
repeats: false
) { _ in
Storage.shared.writeAsync { db in
TypingIndicators.didStopTyping(db, threadId: threadId, direction: direction)
}
}
}
private func scheduleRefreshCallback(
_ db: Database,
shouldSend: Bool = true,
using dependencies: Dependencies
) {
if shouldSend {
try? MessageSender.send(
db,
message: TypingIndicator(kind: .started),
interactionId: nil,
threadId: threadId,
threadVariant: threadVariant,
using: dependencies
)
}
refreshTimer?.invalidate()
refreshTimer = Timer.scheduledTimerOnMainThread(
withTimeInterval: 10,
repeats: false
) { [weak self] _ in
dependencies.storage.writeAsync { db in
self?.scheduleRefreshCallback(db, using: dependencies)
}
}
}
}
// MARK: - Variables
public static let shared: TypingIndicators = TypingIndicators()
private static var outgoing: Atomic<[String: Indicator]> = Atomic([:])
private static var incoming: Atomic<[String: Indicator]> = Atomic([:])
// MARK: - Functions
public static func didStartTypingNeedsToStart(
threadId: String,
threadVariant: SessionThread.Variant,
threadIsBlocked: Bool,
threadIsMessageRequest: Bool,
direction: Direction,
timestampMs: Int64?
) -> Bool {
switch direction {
case .outgoing:
// If we already have an existing typing indicator for this thread then just
// refresh it's timeout (no need to do anything else)
if let existingIndicator: Indicator = outgoing.wrappedValue[threadId] {
existingIndicator.refreshTimeout()
return false
}
let newIndicator: Indicator? = Indicator(
threadId: threadId,
threadVariant: threadVariant,
threadIsBlocked: threadIsBlocked,
threadIsMessageRequest: threadIsMessageRequest,
direction: direction,
timestampMs: timestampMs
)
newIndicator?.refreshTimeout()
outgoing.mutate { $0[threadId] = newIndicator }
return true
case .incoming:
// If we already have an existing typing indicator for this thread then just
// refresh it's timeout (no need to do anything else)
if let existingIndicator: Indicator = incoming.wrappedValue[threadId] {
existingIndicator.refreshTimeout()
return false
}
let newIndicator: Indicator? = Indicator(
threadId: threadId,
threadVariant: threadVariant,
threadIsBlocked: threadIsBlocked,
threadIsMessageRequest: threadIsMessageRequest,
direction: direction,
timestampMs: timestampMs
)
newIndicator?.refreshTimeout()
incoming.mutate { $0[threadId] = newIndicator }
return true
}
}
public static func start(_ db: Database, threadId: String, direction: Direction) {
switch direction {
case .outgoing: outgoing.wrappedValue[threadId]?.start(db)
case .incoming: incoming.wrappedValue[threadId]?.start(db)
}
}
public static func didStopTyping(_ db: Database, threadId: String, direction: Direction) {
switch direction {
case .outgoing:
if let indicator: Indicator = outgoing.wrappedValue[threadId] {
indicator.stop(db)
outgoing.mutate { $0[threadId] = nil }
}
case .incoming:
if let indicator: Indicator = incoming.wrappedValue[threadId] {
indicator.stop(db)
incoming.mutate { $0[threadId] = nil }
}
}
}
}