Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Contacts/TSThread.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

public import GRDB
public import LibSignalClient

public enum TSThreadMentionNotificationMode: UInt {
    case `default` = 0
    case always = 1
    case never = 2
}

public enum TSThreadStoryViewMode: UInt {
    case `default` = 0
    case explicit = 1
    case blockList = 2
    case disabled = 3
}

@objc
open class TSThread: NSObject, SDSCodableModel, InheritableRecord {
    public static let databaseTableName: String = "model_TSThread"
    public class var recordType: SDSRecordType { .thread }

    static func concreteType(forRecordType recordType: UInt) -> (any InheritableRecord.Type)? {
        switch recordType {
        case SDSRecordType.thread.rawValue: TSThread.self
        case SDSRecordType.contactThread.rawValue: TSContactThread.self
        case SDSRecordType.groupThread.rawValue: TSGroupThread.self
        case SDSRecordType.privateStoryThread.rawValue: TSPrivateStoryThread.self
        default: nil
        }
    }

    public var id: Int64?
    public var sqliteRowId: Int64? { self.id }

    @objc
    public let uniqueId: String

    public let creationDate: Date?
    public let isArchivedObsolete: Bool
    // zero if thread has never had an interaction.
    // The corresponding interaction may have been deleted.
    public internal(set) var lastInteractionRowId: UInt64
    public internal(set) var messageDraft: String?
    public internal(set) var shouldThreadBeVisible: Bool
    public let isMarkedUnreadObsolete: Bool
    public private(set) var messageDraftBodyRanges: MessageBodyRanges?
    public private(set) var mentionNotificationMode: TSThreadMentionNotificationMode
    public let mutedUntilTimestampObsolete: UInt64
    public private(set) var lastSentStoryTimestamp: UInt64?
    public internal(set) var storyViewMode: TSThreadStoryViewMode
    public private(set) var editTargetTimestamp: UInt64?
    // These are used to maintain the ordering of drafts in the chat list.
    // When a draft is saved, the lastDraftInteractionRowId for that thread
    // should be set to the max lastInteractionRowId across all threads to
    // prioritize it in the chat list. lastDraftUpdateTimestamp
    // can be used to break ties between threads with the same lastDraftInteractionRowId.
    public internal(set) var lastDraftInteractionRowId: UInt64
    public internal(set) var lastDraftUpdateTimestamp: UInt64

    public enum CodingKeys: String, CodingKey, ColumnExpression {
        case id
        case recordType
        case uniqueId

        case conversationColorNameObsolete = "conversationColorName"
        case creationDate
        case editTargetTimestamp
        case isArchivedObsolete = "isArchived"
        case isMarkedUnreadObsolete = "isMarkedUnread"
        case lastDraftInteractionRowId
        case lastDraftUpdateTimestamp
        case lastInteractionRowId
        case lastSentStoryTimestamp
        case lastVisibleSortIdObsolete = "lastVisibleSortId"
        case lastVisibleSortIdOnScreenPercentageObsolete = "lastVisibleSortIdOnScreenPercentage"
        case mentionNotificationMode
        case messageDraft
        case messageDraftBodyRanges
        case mutedUntilDateObsolete = "mutedUntilDate"
        case mutedUntilTimestampObsolete = "mutedUntilTimestamp"
        case shouldThreadBeVisible
        case storyViewMode
    }

    public required init(inheritableDecoder decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decodeIfPresent(Int64.self, forKey: .id)
        self.uniqueId = try container.decode(String.self, forKey: .uniqueId)
        self.creationDate = try container.decodeIfPresent(TimeInterval.self, forKey: .creationDate).map(Date.init(timeIntervalSince1970:))
        self.editTargetTimestamp = try container.decodeIfPresent(UInt64.self, forKey: .editTargetTimestamp)
        self.isArchivedObsolete = try container.decode(Bool.self, forKey: .isArchivedObsolete)
        self.isMarkedUnreadObsolete = try container.decode(Bool.self, forKey: .isMarkedUnreadObsolete)
        self.lastDraftInteractionRowId = try container.decode(UInt64.self, forKey: .lastDraftInteractionRowId)
        self.lastDraftUpdateTimestamp = try container.decode(UInt64.self, forKey: .lastDraftUpdateTimestamp)
        self.lastInteractionRowId = try container.decode(UInt64.self, forKey: .lastInteractionRowId)
        self.lastSentStoryTimestamp = try container.decodeIfPresent(UInt64.self, forKey: .lastSentStoryTimestamp)
        self.mentionNotificationMode = TSThreadMentionNotificationMode(rawValue: try container.decode(UInt.self, forKey: .mentionNotificationMode)) ?? .default
        self.messageDraft = try container.decodeIfPresent(String.self, forKey: .messageDraft)
        self.messageDraftBodyRanges = try container.decodeIfPresent(Data.self, forKey: .messageDraftBodyRanges).map({ try LegacySDSSerializer().deserializeLegacySDSData($0, ofClass: MessageBodyRanges.self) })
        self.mutedUntilTimestampObsolete = try container.decode(UInt64.self, forKey: .mutedUntilTimestampObsolete)
        self.shouldThreadBeVisible = try container.decode(Bool.self, forKey: .shouldThreadBeVisible)
        self.storyViewMode = TSThreadStoryViewMode(rawValue: try container.decode(UInt.self, forKey: .storyViewMode)) ?? .default
    }

    public func encode(to encoder: any Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(self.id, forKey: .id)
        try container.encode(Self.recordType.rawValue, forKey: .recordType)
        try container.encode(self.uniqueId, forKey: .uniqueId)
        try container.encode("", forKey: .conversationColorNameObsolete)
        try container.encode(self.creationDate?.timeIntervalSince1970, forKey: .creationDate)
        try container.encode(self.editTargetTimestamp, forKey: .editTargetTimestamp)
        try container.encode(self.isArchivedObsolete, forKey: .isArchivedObsolete)
        try container.encode(self.lastDraftInteractionRowId, forKey: .lastDraftInteractionRowId)
        try container.encode(self.lastDraftUpdateTimestamp, forKey: .lastDraftUpdateTimestamp)
        try container.encode(self.lastInteractionRowId, forKey: .lastInteractionRowId)
        try container.encode(self.lastSentStoryTimestamp, forKey: .lastSentStoryTimestamp)
        try container.encode(0 as UInt64, forKey: .lastVisibleSortIdObsolete)
        try container.encode(0 as Double, forKey: .lastVisibleSortIdOnScreenPercentageObsolete)
        try container.encode(self.mentionNotificationMode.rawValue, forKey: .mentionNotificationMode)
        try container.encode(self.messageDraft, forKey: .messageDraft)
        let messageDraftBodyRangesData = self.messageDraftBodyRanges.map(LegacySDSSerializer().serializeAsLegacySDSData(_:))
        try container.encode(messageDraftBodyRangesData, forKey: .messageDraftBodyRanges)
        try container.encode(nil as Date?, forKey: .mutedUntilDateObsolete)
        try container.encode(self.mutedUntilTimestampObsolete, forKey: .mutedUntilTimestampObsolete)
        try container.encode(self.shouldThreadBeVisible, forKey: .shouldThreadBeVisible)
        try container.encode(self.storyViewMode.rawValue, forKey: .storyViewMode)
    }

    init(
        id: Int64?,
        uniqueId: String,
        creationDate: Date?,
        editTargetTimestamp: UInt64?,
        isArchivedObsolete: Bool,
        isMarkedUnreadObsolete: Bool,
        lastDraftInteractionRowId: UInt64,
        lastDraftUpdateTimestamp: UInt64,
        lastInteractionRowId: UInt64,
        lastSentStoryTimestamp: UInt64?,
        mentionNotificationMode: TSThreadMentionNotificationMode,
        messageDraft: String?,
        messageDraftBodyRanges: MessageBodyRanges?,
        mutedUntilTimestampObsolete: UInt64,
        shouldThreadBeVisible: Bool,
        storyViewMode: TSThreadStoryViewMode,
    ) {
        self.id = id
        self.uniqueId = uniqueId
        self.creationDate = creationDate
        self.editTargetTimestamp = editTargetTimestamp
        self.isArchivedObsolete = isArchivedObsolete
        self.isMarkedUnreadObsolete = isMarkedUnreadObsolete
        self.lastDraftInteractionRowId = lastDraftInteractionRowId
        self.lastDraftUpdateTimestamp = lastDraftUpdateTimestamp
        self.lastInteractionRowId = lastInteractionRowId
        self.lastSentStoryTimestamp = lastSentStoryTimestamp
        self.mentionNotificationMode = mentionNotificationMode
        self.messageDraft = messageDraft
        self.messageDraftBodyRanges = messageDraftBodyRanges
        self.mutedUntilTimestampObsolete = mutedUntilTimestampObsolete
        self.shouldThreadBeVisible = shouldThreadBeVisible
        self.storyViewMode = storyViewMode
    }

    init(uniqueId: String) {
        self.isArchivedObsolete = false
        self.isMarkedUnreadObsolete = false
        self.lastDraftInteractionRowId = 0
        self.lastDraftUpdateTimestamp = 0
        self.lastInteractionRowId = 0
        self.mentionNotificationMode = .default
        self.messageDraft = nil
        self.mutedUntilTimestampObsolete = 0
        self.shouldThreadBeVisible = false
        self.storyViewMode = .default

        self.uniqueId = uniqueId
        self.creationDate = Date()
    }

    func deepCopy() -> TSThread {
        return TSThread(
            id: self.id,
            uniqueId: self.uniqueId,
            creationDate: self.creationDate,
            editTargetTimestamp: self.editTargetTimestamp,
            isArchivedObsolete: self.isArchivedObsolete,
            isMarkedUnreadObsolete: self.isMarkedUnreadObsolete,
            lastDraftInteractionRowId: self.lastDraftInteractionRowId,
            lastDraftUpdateTimestamp: self.lastDraftUpdateTimestamp,
            lastInteractionRowId: self.lastInteractionRowId,
            lastSentStoryTimestamp: self.lastSentStoryTimestamp,
            mentionNotificationMode: self.mentionNotificationMode,
            messageDraft: self.messageDraft,
            messageDraftBodyRanges: self.messageDraftBodyRanges,
            mutedUntilTimestampObsolete: self.mutedUntilTimestampObsolete,
            shouldThreadBeVisible: self.shouldThreadBeVisible,
            storyViewMode: self.storyViewMode,
        )
    }

    override public var hash: Int {
        var hasher = Hasher()
        hasher.combine(self.id)
        hasher.combine(self.uniqueId)
        hasher.combine(self.creationDate)
        hasher.combine(self.editTargetTimestamp)
        hasher.combine(self.isArchivedObsolete)
        hasher.combine(self.isMarkedUnreadObsolete)
        hasher.combine(self.lastDraftInteractionRowId)
        hasher.combine(self.lastDraftUpdateTimestamp)
        hasher.combine(self.lastInteractionRowId)
        hasher.combine(self.lastSentStoryTimestamp)
        hasher.combine(self.mentionNotificationMode)
        hasher.combine(self.messageDraft)
        hasher.combine(self.messageDraftBodyRanges)
        hasher.combine(self.mutedUntilTimestampObsolete)
        hasher.combine(self.shouldThreadBeVisible)
        hasher.combine(self.storyViewMode)
        return hasher.finalize()
    }

    override public func isEqual(_ object: Any?) -> Bool {
        guard let object = object as? Self else { return false }
        guard self.id == object.id else { return false }
        guard self.uniqueId == object.uniqueId else { return false }
        guard self.creationDate == object.creationDate else { return false }
        guard self.editTargetTimestamp == object.editTargetTimestamp else { return false }
        guard self.isArchivedObsolete == object.isArchivedObsolete else { return false }
        guard self.isMarkedUnreadObsolete == object.isMarkedUnreadObsolete else { return false }
        guard self.lastDraftInteractionRowId == object.lastDraftInteractionRowId else { return false }
        guard self.lastDraftUpdateTimestamp == object.lastDraftUpdateTimestamp else { return false }
        guard self.lastInteractionRowId == object.lastInteractionRowId else { return false }
        guard self.lastSentStoryTimestamp == object.lastSentStoryTimestamp else { return false }
        guard self.mentionNotificationMode == object.mentionNotificationMode else { return false }
        guard self.messageDraft == object.messageDraft else { return false }
        guard self.messageDraftBodyRanges == object.messageDraftBodyRanges else { return false }
        guard self.mutedUntilTimestampObsolete == object.mutedUntilTimestampObsolete else { return false }
        guard self.shouldThreadBeVisible == object.shouldThreadBeVisible else { return false }
        guard self.storyViewMode == object.storyViewMode else { return false }
        return true
    }

    public func anyDidFetchOne(transaction: DBReadTransaction) {
        SSKEnvironment.shared.modelReadCachesRef.threadReadCache.didReadThread(self, transaction: transaction)
    }

    public func anyDidEnumerateOne(transaction: DBReadTransaction) {
        SSKEnvironment.shared.modelReadCachesRef.threadReadCache.didReadThread(self, transaction: transaction)
    }

    open func anyWillInsert(transaction: DBWriteTransaction) {
    }

    public func anyDidInsert(transaction: DBWriteTransaction) {
        ThreadAssociatedData.create(for: self.uniqueId, transaction: transaction)

        if self.shouldThreadBeVisible, !SSKPreferences.hasSavedThread(transaction: transaction) {
            SSKPreferences.setHasSavedThread(true, transaction: transaction)
        }

        _anyDidInsert(tx: transaction)

        SSKEnvironment.shared.modelReadCachesRef.threadReadCache.didInsertOrUpdate(thread: self, transaction: transaction)
    }

    public func anyWillUpdate(transaction: DBWriteTransaction) {
    }

    public func anyDidUpdate(transaction: DBWriteTransaction) {
        if self.shouldThreadBeVisible, !SSKPreferences.hasSavedThread(transaction: transaction) {
            SSKPreferences.setHasSavedThread(true, transaction: transaction)
        }

        SSKEnvironment.shared.modelReadCachesRef.threadReadCache.didInsertOrUpdate(thread: self, transaction: transaction)

        DependenciesBridge.shared.pinnedThreadManager.handleUpdatedThread(self, tx: transaction)
    }

    public var isNoteToSelf: Bool { false }

    public final var recipientAddressesWithSneakyTransaction: [SignalServiceAddress] {
        let databaseStorage = SSKEnvironment.shared.databaseStorageRef
        return databaseStorage.read { tx in self.recipientAddresses(with: tx) }
    }

    @objc
    public func recipientAddresses(with tx: DBReadTransaction) -> [SignalServiceAddress] {
        owsFail("abstract method")
    }

    public func hasSafetyNumbers() -> Bool { false }

    public func lastInteractionForInbox(forChatListSorting isForSorting: Bool, transaction tx: DBReadTransaction) -> TSInteraction? {
        return InteractionFinder(threadUniqueId: self.uniqueId).mostRecentInteractionForInbox(forChatListSorting: isForSorting, transaction: tx)
    }

    public func firstInteraction(atOrAroundSortId sortId: UInt64, transaction tx: DBReadTransaction) -> TSInteraction? {
        return InteractionFinder(threadUniqueId: self.uniqueId).firstInteraction(atOrAroundSortId: sortId, transaction: tx)
    }

    func merge(from otherThread: TSThread) {
        self.shouldThreadBeVisible = self.shouldThreadBeVisible || otherThread.shouldThreadBeVisible
        self.lastInteractionRowId = max(self.lastInteractionRowId, otherThread.lastInteractionRowId)

        // Copy the draft if this thread doesn't have one. We always assign both
        // values if we assign one of them since they're related.
        if self.messageDraft == nil {
            self.messageDraft = otherThread.messageDraft
            self.messageDraftBodyRanges = otherThread.messageDraftBodyRanges
            self.lastDraftInteractionRowId = otherThread.lastDraftInteractionRowId
            self.lastDraftUpdateTimestamp = otherThread.lastDraftUpdateTimestamp
        }
    }

    public typealias RowId = Int64

    public var logString: String {
        return (self as? TSGroupThread)?.groupId.toHex() ?? self.uniqueId
    }

    @objc
    public class func fetchViaCacheObjC(uniqueId: String, transaction: DBReadTransaction) -> TSThread? {
        return fetchViaCache(uniqueId: uniqueId, transaction: transaction)
    }

    public class func fetchViaCache(uniqueId: String, transaction: DBReadTransaction) -> Self? {
        let cache = SSKEnvironment.shared.modelReadCachesRef.threadReadCache
        guard let thread = cache.getThread(uniqueId: uniqueId, transaction: transaction) else {
            return nil
        }
        guard let typedThread = thread as? Self else {
            owsFailDebug("object has type \(type(of: thread)), not \(Self.self)")
            return nil
        }
        return typedThread
    }

    // MARK: - updateWith...

    public func updateWithDraft(
        draftMessageBody: MessageBody?,
        replyInfo: ThreadReplyInfo?,
        editTargetTimestamp: UInt64?,
        transaction tx: DBWriteTransaction,
    ) {
        let mostRecentInteractionID = InteractionFinder.maxInteractionRowId(transaction: tx)

        anyUpdate(transaction: tx) { thread in
            thread.messageDraft = draftMessageBody?.text
            thread.messageDraftBodyRanges = draftMessageBody?.ranges
            thread.editTargetTimestamp = editTargetTimestamp

            if draftMessageBody?.text.nilIfEmpty == nil {
                // 0 makes these values effectively irrelevant since they will
                // be compared to the lastInteractionRowId, which will always be > 0.
                thread.lastDraftInteractionRowId = 0
                thread.lastDraftUpdateTimestamp = 0
            } else {
                thread.lastDraftInteractionRowId = mostRecentInteractionID
                thread.lastDraftUpdateTimestamp = Date().ows_millisecondsSince1970
                thread.shouldThreadBeVisible = true
            }
        }

        if let replyInfo {
            DependenciesBridge.shared.threadReplyInfoStore
                .save(replyInfo, for: uniqueId, tx: tx)
        } else {
            DependenciesBridge.shared.threadReplyInfoStore
                .remove(for: uniqueId, tx: tx)
        }
    }

    public func updateWithMentionNotificationMode(
        _ mentionNotificationMode: TSThreadMentionNotificationMode,
        wasLocallyInitiated: Bool,
        transaction tx: DBWriteTransaction,
    ) {
        anyUpdate(transaction: tx) { thread in
            thread.mentionNotificationMode = mentionNotificationMode
        }

        if
            wasLocallyInitiated,
            let groupThread = self as? TSGroupThread,
            groupThread.isGroupV2Thread
        {
            SSKEnvironment.shared.storageServiceManagerRef.recordPendingUpdates(
                groupModel: groupThread.groupModel,
            )
        }
    }

    /// Updates `shouldThreadBeVisible`.
    public func updateWithShouldThreadBeVisible(
        _ shouldThreadBeVisible: Bool,
        transaction tx: DBWriteTransaction,
    ) {
        anyUpdate(transaction: tx) { thread in
            thread.shouldThreadBeVisible = true
        }
    }

    public func updateWithLastSentStoryTimestamp(
        _ lastSentStoryTimestamp: UInt64,
        transaction tx: DBWriteTransaction,
    ) {
        anyUpdate(transaction: tx) { thread in
            if lastSentStoryTimestamp > thread.lastSentStoryTimestamp ?? 0 {
                thread.lastSentStoryTimestamp = lastSentStoryTimestamp
            }
        }
    }

    // MARK: -

    func updateWithInsertedInteraction(_ interaction: TSInteraction, tx: DBWriteTransaction) {
        updateWithInteraction(interaction, wasInteractionInserted: true, tx: tx)
    }

    func updateWithUpdatedInteraction(_ interaction: TSInteraction, tx: DBWriteTransaction) {
        updateWithInteraction(interaction, wasInteractionInserted: false, tx: tx)
    }

    private func updateWithInteraction(_ interaction: TSInteraction, wasInteractionInserted: Bool, tx: DBWriteTransaction) {
        let db = DependenciesBridge.shared.db

        let hasLastVisibleInteraction = hasLastVisibleInteraction(transaction: tx)
        let needsToClearLastVisibleSortId = hasLastVisibleInteraction && wasInteractionInserted

        if !interaction.shouldAppearInInbox(transaction: tx) {
            // We want to clear the last visible sort ID on any new message,
            // even if the message doesn't appear in the inbox view.
            if needsToClearLastVisibleSortId {
                clearLastVisibleInteraction(transaction: tx)
            }
            scheduleTouchFinalization(transaction: tx)
            return
        }

        let interactionRowId = UInt64(interaction.sqliteRowId ?? 0)
        let needsToMarkAsVisible = !shouldThreadBeVisible
        let threadAssociatedData = ThreadAssociatedData.fetchOrDefault(for: self, transaction: tx)
        let needsToClearArchived = shouldClearArchivedStatusWhenUpdatingWithInteraction(
            interaction,
            wasInteractionInserted: wasInteractionInserted,
            threadAssociatedData: threadAssociatedData,
            tx: tx,
        )
        let needsToUpdateLastInteractionRowId = interactionRowId > lastInteractionRowId
        let needsToClearIsMarkedUnread = threadAssociatedData.isMarkedUnread && wasInteractionInserted
        let needsUpdatedRowId = interaction.shouldBumpThreadToTopOfChatList(transaction: tx)

        if
            needsToMarkAsVisible
            || needsToClearArchived
            || needsToUpdateLastInteractionRowId
            || needsToClearLastVisibleSortId
            || needsToClearIsMarkedUnread
        {
            anyUpdate(transaction: tx) { thread in
                thread.shouldThreadBeVisible = true
                if needsUpdatedRowId {
                    thread.lastInteractionRowId = max(thread.lastInteractionRowId, interactionRowId)
                }
            }

            threadAssociatedData.clear(
                isArchived: needsToClearArchived,
                isMarkedUnread: needsToClearIsMarkedUnread,
                updateStorageService: true,
                transaction: tx,
            )

            if needsToMarkAsVisible {
                // Non-visible threads don't get indexed, so if we're becoming
                // visible for the first time...
                db.touch(
                    thread: self,
                    shouldReindex: true,
                    shouldUpdateChatListUi: true,
                    tx: tx,
                )
            }

            if needsToClearLastVisibleSortId {
                clearLastVisibleInteraction(transaction: tx)
            }
        } else {
            scheduleTouchFinalization(transaction: tx)
        }
    }

    private func shouldClearArchivedStatusWhenUpdatingWithInteraction(
        _ interaction: TSInteraction,
        wasInteractionInserted: Bool,
        threadAssociatedData: ThreadAssociatedData,
        tx: DBReadTransaction,
    ) -> Bool {
        var needsToClearArchived = threadAssociatedData.isArchived && wasInteractionInserted

        // I'm not sure, at the time I am migrating this to Swift, if this is
        // a load-bearing check of some sort. Perhaps in the future, we can
        // more confidently remove this.
        if
            !CurrentAppContext().isRunningTests,
            !AppReadinessObjcBridge.isAppReady
        {
            needsToClearArchived = false
        }

        if let infoMessage = interaction as? TSInfoMessage {
            switch infoMessage.messageType {
            case
                .syncedThread,
                .threadMerge:
                needsToClearArchived = false
            case
                .typeLocalUserEndedSession,
                .typeRemoteUserEndedSession,
                .userNotRegistered,
                .typeUnsupportedMessage,
                .typeGroupUpdate,
                .typeGroupQuit,
                .typeDisappearingMessagesUpdate,
                .addToContactsOffer,
                .verificationStateChange,
                .addUserToProfileWhitelistOffer,
                .addGroupToProfileWhitelistOffer,
                .unknownProtocolVersion,
                .userJoinedSignal,
                .profileUpdate,
                .phoneNumberChange,
                .recipientHidden,
                .paymentsActivationRequest,
                .paymentsActivated,
                .sessionSwitchover,
                .reportedSpam,
                .learnedProfileName,
                .blockedOtherUser,
                .blockedGroup,
                .unblockedOtherUser,
                .unblockedGroup,
                .acceptedMessageRequest,
                .typeEndPoll,
                .typePinnedMessage:
                break
            }
        }

        // Shouldn't clear archived if:
        // - The thread is muted.
        // - The user has requested we keep muted chats archived.
        // - The message was sent by someone other than the current user. (If the
        //   current user sent the message, we should clear archived.)
        let wasMessageSentByUs = interaction is TSOutgoingMessage
        if
            threadAssociatedData.isMuted,
            SSKPreferences.shouldKeepMutedChatsArchived(transaction: tx),
            !wasMessageSentByUs
        {
            needsToClearArchived = false
        }

        return needsToClearArchived
    }

    // MARK: -

    public func updateWithRemovedInteraction(
        _ interaction: TSInteraction,
        tx: DBWriteTransaction,
    ) {
        let interactionRowId = interaction.sqliteRowId ?? 0
        let needsToUpdateLastInteractionRowId = interactionRowId == lastInteractionRowId

        let lastVisibleSortId = lastVisibleSortId(transaction: tx) ?? 0
        let needsToUpdateLastVisibleSortId = lastVisibleSortId > 0 && lastVisibleSortId == interactionRowId

        updateOnInteractionsRemoved(
            needsToUpdateLastInteractionRowId: needsToUpdateLastInteractionRowId,
            needsToUpdateLastVisibleSortId: needsToUpdateLastVisibleSortId,
            lastVisibleSortId: lastVisibleSortId,
            tx: tx,
        )
    }

    public func updateOnInteractionsRemoved(
        needsToUpdateLastInteractionRowId: Bool,
        needsToUpdateLastVisibleSortId: Bool,
        tx: DBWriteTransaction,
    ) {
        updateOnInteractionsRemoved(
            needsToUpdateLastInteractionRowId: needsToUpdateLastInteractionRowId,
            needsToUpdateLastVisibleSortId: needsToUpdateLastVisibleSortId,
            lastVisibleSortId: lastVisibleSortId(transaction: tx) ?? 0,
            tx: tx,
        )
    }

    private func updateOnInteractionsRemoved(
        needsToUpdateLastInteractionRowId: Bool,
        needsToUpdateLastVisibleSortId: Bool,
        lastVisibleSortId: UInt64,
        tx: DBWriteTransaction,
    ) {
        if needsToUpdateLastInteractionRowId || needsToUpdateLastVisibleSortId {
            anyUpdate(transaction: tx) { thread in
                if needsToUpdateLastInteractionRowId {
                    let lastInteraction = thread.lastInteractionForInbox(forChatListSorting: true, transaction: tx)
                    thread.lastInteractionRowId = lastInteraction?.sortId ?? 0
                }
            }

            if needsToUpdateLastVisibleSortId {
                if
                    let interactionBeforeRemovedInteraction = firstInteraction(
                        atOrAroundSortId: lastVisibleSortId,
                        transaction: tx,
                    )
                {
                    setLastVisibleInteraction(
                        sortId: interactionBeforeRemovedInteraction.sortId,
                        onScreenPercentage: 1.0,
                        transaction: tx,
                    )
                } else {
                    clearLastVisibleInteraction(transaction: tx)
                }
            }
        } else {
            scheduleTouchFinalization(transaction: tx)
        }
    }

    // MARK: -

    @objc
    func scheduleTouchFinalization(transaction tx: DBWriteTransaction) {
        tx.addFinalizationBlock(key: uniqueId) { tx in
            let databaseStorage = SSKEnvironment.shared.databaseStorageRef

            guard let selfThread = Self.fetchViaCache(uniqueId: self.uniqueId, transaction: tx) else {
                return
            }

            databaseStorage.touch(thread: selfThread, shouldReindex: false, tx: tx)
        }
    }

    public func canUserEditPinnedMessages(aci: Aci, tx: DBReadTransaction) -> Bool {
        guard !hasPendingMessageRequest(transaction: tx) else {
            return false
        }

        guard
            let groupThread = self as? TSGroupThread
        else {
            // Not a group thread, so no additional access to check.
            return true
        }

        guard
            groupThread.groupModel.groupMembership.isFullMember(aci),
            let groupModel = groupThread.groupModel as? TSGroupModelV2
        else {
            return false
        }

        // Admins are good to pin.
        if groupModel.groupMembership.isFullMemberAndAdministrator(aci) {
            return true
        }

        // User is not an admin. Can't pin if its announcements-only group, or edit group is admin only.
        return groupModel.access.attributes != .administrator && !groupModel.isAnnouncementsOnly
    }
}

// MARK: - StringInterpolation

public extension String.StringInterpolation {
    mutating func appendInterpolation(threadColumn column: TSThread.CodingKeys) {
        appendLiteral(column.rawValue)
    }

    mutating func appendInterpolation(threadColumnFullyQualified column: TSThread.CodingKeys) {
        appendLiteral("\(TSThread.databaseTableName).\(column.rawValue)")
    }
}