// // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import GRDB import SignalCoreKit @objc public class ThreadAssociatedData: NSObject, Codable, FetchableRecord, PersistableRecord { public static let databaseTableName = "thread_associated_data" public private(set) var id: Int64? public let threadUniqueId: String @objc @DecodableDefault.False public private(set) var isArchived: Bool @objc @DecodableDefault.False public private(set) var isMarkedUnread: Bool @objc @DecodableDefault.Zero public private(set) var mutedUntilTimestamp: UInt64 @objc @DecodableDefault.OneFloat public private(set) var audioPlaybackRate: Float @objc public var isMuted: Bool { mutedUntilTimestamp > Date.ows_millisecondTimestamp() } @objc public var mutedUntilDate: Date? { guard mutedUntilTimestamp > 0 else { return nil } return Date(millisecondsSince1970: mutedUntilTimestamp) } @objc public static var alwaysMutedTimestamp: UInt64 { UInt64(LLONG_MAX) } @objc(fetchOrDefaultForThread:transaction:) public static func fetchOrDefault( for thread: TSThread, transaction: SDSAnyReadTransaction ) -> ThreadAssociatedData { fetchOrDefault(for: thread.uniqueId, ignoreMissing: false, transaction: transaction) } @objc(fetchOrDefaultForThreadUniqueId:transaction:) public static func fetchOrDefault( for threadUniqueId: String, transaction: SDSAnyReadTransaction ) -> ThreadAssociatedData { fetchOrDefault(for: threadUniqueId, ignoreMissing: false, transaction: transaction) } @objc(fetchOrDefaultForThread:ignoreMissing:transaction:) public static func fetchOrDefault( for thread: TSThread, ignoreMissing: Bool, transaction: SDSAnyReadTransaction ) -> ThreadAssociatedData { fetchOrDefault(for: thread.uniqueId, ignoreMissing: ignoreMissing, transaction: transaction) } @objc(fetchOrDefaultForThreadUniqueId:ignoreMissing:transaction:) public static func fetchOrDefault( for threadUniqueId: String, ignoreMissing: Bool, transaction: SDSAnyReadTransaction ) -> ThreadAssociatedData { DependenciesBridge.shared.threadAssociatedDataStore.fetchOrDefault( for: threadUniqueId, ignoreMissing: ignoreMissing || CurrentAppContext().isRunningTests || threadUniqueId == "MockThread", tx: transaction.asV2Read ) } @objc(fetchForThreadUniqueId:transaction:) public static func fetch( for threadUniqueId: String, transaction: SDSAnyReadTransaction ) -> ThreadAssociatedData? { do { return try Self.filter(Column("threadUniqueId") == threadUniqueId).fetchOne(transaction.unwrapGrdbRead.database) } catch { owsFailDebug("Failed to read associated data \(error)") return nil } } @objc public static func create(for threadUniqueId: String, transaction: SDSAnyWriteTransaction) { let threadAssociatedDataStore = DependenciesBridge.shared.threadAssociatedDataStore guard threadAssociatedDataStore.fetch(for: threadUniqueId, tx: transaction.asV2Write) == nil else { return } do { try ThreadAssociatedData(threadUniqueId: threadUniqueId).insert(transaction.unwrapGrdbWrite.database) } catch { owsFailDebug("Unexpectedly failed to insert \(error)") } } init(threadUniqueId: String) { self.threadUniqueId = threadUniqueId super.init() } public func didInsert(with rowID: Int64, for column: String?) { self.id = rowID } @objc public init( threadUniqueId: String, isArchived: Bool, isMarkedUnread: Bool, mutedUntilTimestamp: UInt64, audioPlaybackRate: Float ) { self.threadUniqueId = threadUniqueId self.isArchived = isArchived self.isMarkedUnread = isMarkedUnread self.mutedUntilTimestamp = mutedUntilTimestamp self.audioPlaybackRate = audioPlaybackRate super.init() } public func updateWith( isArchived: Bool? = nil, isMarkedUnread: Bool? = nil, mutedUntilTimestamp: UInt64? = nil, audioPlaybackRate: Float? = nil, updateStorageService: Bool, transaction: SDSAnyWriteTransaction ) { guard isArchived != nil || isMarkedUnread != nil || mutedUntilTimestamp != nil || audioPlaybackRate != nil else { return owsFailDebug("You must set one value") } var isMarkedUnread = isMarkedUnread // If we're archiving and we have an existing thread record, // also mark that thread record as read. if isArchived == true { // Also clear marked unread if we're not explicitly setting it. if isMarkedUnread == nil { isMarkedUnread = false } markThreadAsReadIfExists(transaction: transaction) } updateWith(updateStorageService: updateStorageService, transaction: transaction) { associatedData in if let isArchived = isArchived { associatedData.isArchived = isArchived } if let isMarkedUnread = isMarkedUnread { associatedData.isMarkedUnread = isMarkedUnread } if let mutedUntilTimestamp = mutedUntilTimestamp { associatedData.mutedUntilTimestamp = mutedUntilTimestamp } if let audioPlaybackRate = audioPlaybackRate { associatedData.audioPlaybackRate = audioPlaybackRate } } } @objc @available(swift, obsoleted: 1.0) public func updateWith(isArchived: Bool, updateStorageService: Bool, transaction: SDSAnyWriteTransaction) { updateWith(isArchived: isArchived, updateStorageService: updateStorageService, transaction: transaction) } @objc @available(swift, obsoleted: 1.0) public func updateWith(isMarkedUnread: Bool, updateStorageService: Bool, transaction: SDSAnyWriteTransaction) { updateWith(isMarkedUnread: isMarkedUnread, updateStorageService: updateStorageService, transaction: transaction) } @objc @available(swift, obsoleted: 1.0) public func updateWith(mutedUntilTimestamp: UInt64, updateStorageService: Bool, transaction: SDSAnyWriteTransaction) { updateWith(updateStorageService: updateStorageService, transaction: transaction) { $0.mutedUntilTimestamp = mutedUntilTimestamp } } @objc @available(swift, obsoleted: 1.0) public func updateWith(audioPlaybackRate: Float, updateStorageService: Bool, transaction: SDSAnyWriteTransaction) { updateWith(updateStorageService: updateStorageService, transaction: transaction) { $0.audioPlaybackRate = audioPlaybackRate } } @objc(clearIsArchived:clearIsMarkedUnread:updateStorageService:transaction:) public func clear(isArchived clearIsArchived: Bool = false, isMarkedUnread clearIsMarkedUnread: Bool = false, updateStorageService: Bool, transaction: SDSAnyWriteTransaction) { guard clearIsArchived || clearIsMarkedUnread else { return } updateWith(updateStorageService: updateStorageService, transaction: transaction) { associatedData in if clearIsArchived { associatedData.isArchived = false } if clearIsMarkedUnread { associatedData.isMarkedUnread = false } } } private func updateWith(updateStorageService: Bool, transaction: SDSAnyWriteTransaction, block: (ThreadAssociatedData) -> Void) { block(self) let threadAssociatedDataStore = DependenciesBridge.shared.threadAssociatedDataStore let storedCopy = threadAssociatedDataStore.fetch(for: threadUniqueId, tx: transaction.asV2Read) if let storedCopy, storedCopy !== self { block(storedCopy) } if let storedCopy { do { try storedCopy.update(transaction.unwrapGrdbWrite.database) } catch { owsFailDebug("Unexpectedly failed to update \(error)") } } else { do { owsFailDebug("Could not update missing record.") try insert(transaction.unwrapGrdbWrite.database) } catch { owsFailDebug("Unexpectedly failed to insert \(error)") } } // If the thread model exists, make sure the UI is notified that it has changed. if let thread = TSThread.anyFetch(uniqueId: threadUniqueId, transaction: transaction) { Self.databaseStorage.touch(thread: thread, shouldReindex: false, transaction: transaction) } if updateStorageService { guard let thread = TSThread.anyFetch(uniqueId: threadUniqueId, transaction: transaction) else { return owsFailDebug("Unexpectedly missing thread for storage service update.") } if let groupThread = thread as? TSGroupThread { storageServiceManager.recordPendingUpdates(groupModel: groupThread.groupModel) } else if let contactThread = thread as? TSContactThread { storageServiceManager.recordPendingUpdates(updatedAddresses: [contactThread.contactAddress]) } else { owsFailDebug("Unexpected thread type") } } } fileprivate func markThreadAsReadIfExists(transaction: SDSAnyWriteTransaction) { guard let thread = TSThread.anyFetch(uniqueId: threadUniqueId, transaction: transaction) else { return } thread.markAllAsRead(transaction: transaction) } } public extension TSThread { @objc(markAllAsReadAndUpdateStorageService:transaction:) func markAllAsRead(updateStorageService: Bool, transaction: SDSAnyWriteTransaction) { markAllAsRead(transaction: transaction) let associatedData = ThreadAssociatedData.fetchOrDefault(for: self, transaction: transaction) associatedData.updateWith(isMarkedUnread: false, updateStorageService: updateStorageService, transaction: transaction) } fileprivate func markAllAsRead(transaction: SDSAnyWriteTransaction) { let hasPendingMessageRequest = hasPendingMessageRequest(transaction: transaction) let circumstance: OWSReceiptCircumstance = hasPendingMessageRequest ? .onThisDeviceWhilePendingMessageRequest : .onThisDevice let finder = InteractionFinder(threadUniqueId: uniqueId) var cursor = finder.fetchAllUnreadMessages(transaction: transaction) do { while let message = try cursor.next() { message.markAsRead( atTimestamp: Date.ows_millisecondTimestamp(), thread: self, circumstance: circumstance, shouldClearNotifications: true, transaction: transaction ) } } catch { owsFailDebug("unexpected failure fetching unread messages: \(error)") } // Just to be defensive, we'll also check for unread messages. owsAssertDebug(finder.unreadCount(transaction: transaction) == 0) } }