// // 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 } public enum TSThreadType: UInt { case thread = 2 case contactThread = 27 case groupThread = 26 case privateStoryThread = 72 case releaseNotesThread = 80 } @objc open class TSThread: NSObject, SDSCodableModel, InheritableRecord { public static let databaseTableName: String = "model_TSThread" public class var recordType: TSThreadType { .thread } static func concreteType(forRecordType recordType: UInt) -> (any InheritableRecord.Type)? { switch TSThreadType(rawValue: recordType) { case .thread: TSThread.self case .contactThread: TSContactThread.self case .groupThread: TSGroupThread.self case .privateStoryThread: TSPrivateStoryThread.self case .releaseNotesThread: TSReleaseNotesThread.self case nil: 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)") } }