// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB import Sodium import SessionUtilitiesKit import SessionSnodeKit public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "interaction" } internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) internal static let linkPreviewForeignKey = ForeignKey( [Columns.linkPreviewUrl], to: [LinkPreview.Columns.url] ) public static let thread = belongsTo(SessionThread.self, using: threadForeignKey) public static let profile = hasOne(Profile.self, using: Profile.interactionForeignKey) public static let interactionAttachments = hasMany( InteractionAttachment.self, using: InteractionAttachment.interactionForeignKey ) public static let attachments = hasMany( Attachment.self, through: interactionAttachments, using: InteractionAttachment.attachment ) public static let quote = hasOne(Quote.self, using: Quote.interactionForeignKey) /// Whenever using this `linkPreview` association make sure to filter the result using /// `.filter(literal: Interaction.linkPreviewFilterLiteral)` to ensure the correct LinkPreview is returned public static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) public static var linkPreviewFilterLiteral: SQL = { let interaction: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() let halfResolution: Double = LinkPreview.timstampResolution return "(\(interaction[.timestampMs]) BETWEEN (\(linkPreview[.timestamp]) - \(halfResolution)) * 1000 AND (\(linkPreview[.timestamp]) + \(halfResolution)) * 1000)" }() public static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { case id case serverHash case messageUuid case threadId case authorId case variant case body case timestampMs case receivedAtTimestampMs case wasRead case hasMention case expiresInSeconds case expiresStartedAtMs case linkPreviewUrl // Open Group specific properties case openGroupServerMessageId case openGroupWhisperMods case openGroupWhisperTo } public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { case standardIncoming case standardOutgoing case standardIncomingDeleted // Info Message Types (spacing the values out to make it easier to extend) case infoClosedGroupCreated = 1000 case infoClosedGroupUpdated case infoClosedGroupCurrentUserLeft case infoClosedGroupCurrentUserErrorLeaving case infoClosedGroupCurrentUserLeaving case infoDisappearingMessagesUpdate = 2000 case infoScreenshotNotification = 3000 case infoMediaSavedNotification case infoMessageRequestAccepted = 4000 case infoCall = 5000 // MARK: - Convenience public static let variantsToIncrementUnreadCount: [Variant] = [ .standardIncoming, .infoCall ] public var isInfoMessage: Bool { switch self { case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving, .infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification, .infoMessageRequestAccepted, .infoCall: return true case .standardIncoming, .standardOutgoing, .standardIncomingDeleted: return false } } public var isGroupControlMessage: Bool { switch self { case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving: return true default: return false } } public var isGroupLeavingStatus: Bool { switch self { case .infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving: return true default: return false } } /// This flag controls whether the `wasRead` flag is automatically set to true based on the message variant (as a result it they will /// or won't affect the unread count) fileprivate var canBeUnread: Bool { switch self { case .standardIncoming: return true case .infoCall: return true case .standardOutgoing, .standardIncomingDeleted: return false case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving, .infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification, .infoMessageRequestAccepted: return false } } } /// The `id` value is auto incremented by the database, if the `Interaction` hasn't been inserted into /// the database yet this value will be `nil` public private(set) var id: Int64? = nil /// The hash returned by the server when this message was created on the server /// /// **Notes:** /// - This will only be populated for `standardIncoming`/`standardOutgoing` interactions from /// either `contact` or `closedGroup` threads /// - This value will differ for "sync" messages (messages we resend to the current to ensure it appears /// on all linked devices) because the data in the message is slightly different public let serverHash: String? /// The UUID specified when sending the message to allow for custom updating and de-duping behaviours /// /// **Note:** Currently only `infoCall` messages utilise this value public let messageUuid: String? /// The id of the thread that this interaction belongs to (used to expose the `thread` variable) public let threadId: String /// The id of the user who sent the interaction, also used to expose the `profile` variable) /// /// **Note:** For any "info" messages this value will always be the current user public key (this is because these /// messages are created locally based on control messages and the initiator of a control message doesn't always /// get transmitted) public let authorId: String /// The type of interaction public let variant: Variant /// The body of this interaction public let body: String? /// When the interaction was created in milliseconds since epoch /// /// **Notes:** /// - This value will be `0` if it hasn't been set yet /// - The code sorts messages using this value /// - This value will ber overwritten by the `serverTimestamp` for open group messages public let timestampMs: Int64 /// When the interaction was received in milliseconds since epoch /// /// **Note:** This value will be `0` if it hasn't been set yet public let receivedAtTimestampMs: Int64 /// A flag indicating whether the interaction has been read (this is a flag rather than a timestamp because /// we couldn’t know if a read timestamp is accurate) /// /// **Note:** This flag is not applicable to standardOutgoing or standardIncomingDeleted interactions public private(set) var wasRead: Bool /// A flag indicating whether the current user was mentioned in this interaction (or the associated quote) public let hasMention: Bool /// The number of seconds until this message should expire public let expiresInSeconds: TimeInterval? /// The timestamp in milliseconds since 1970 at which this messages expiration timer started counting /// down (this is stored in order to allow the `expiresInSeconds` value to be updated before a /// message has expired) public let expiresStartedAtMs: Double? /// This value is the url for the link preview for this interaction /// /// **Note:** This is also used for open group invitations public let linkPreviewUrl: String? // Open Group specific properties /// The `openGroupServerMessageId` value will only be set for messages from SOGS public let openGroupServerMessageId: Int64? /// This flag indicates whether this interaction is a whisper to the mods of an Open Group public let openGroupWhisperMods: Bool /// This value is the id of the user within an Open Group who is the target of this whisper interaction public let openGroupWhisperTo: String? // MARK: - Relationships public var thread: QueryInterfaceRequest { request(for: Interaction.thread) } public var profile: QueryInterfaceRequest { request(for: Interaction.profile) } /// Depending on the data associated to this interaction this array will represent different things, these /// cases are mutually exclusive: /// /// **Quote:** The thumbnails associated to the `Quote` /// **LinkPreview:** The thumbnails associated to the `LinkPreview` /// **Other:** The files directly attached to the interaction public var attachments: QueryInterfaceRequest { let interactionAttachment: TypedTableAlias = TypedTableAlias() return request(for: Interaction.attachments) .order(interactionAttachment[.albumIndex]) } public var quote: QueryInterfaceRequest { request(for: Interaction.quote) } public var linkPreview: QueryInterfaceRequest { /// **Note:** This equation **MUST** match the `linkPreviewFilterLiteral` logic let halfResolution: Double = LinkPreview.timstampResolution return request(for: Interaction.linkPreview) .filter( (timestampMs >= (LinkPreview.Columns.timestamp - halfResolution) * 1000) && (timestampMs <= (LinkPreview.Columns.timestamp + halfResolution) * 1000) ) } public var recipientStates: QueryInterfaceRequest { request(for: Interaction.recipientStates) } // MARK: - Initialization internal init( id: Int64? = nil, serverHash: String?, messageUuid: String?, threadId: String, authorId: String, variant: Variant, body: String?, timestampMs: Int64, receivedAtTimestampMs: Int64, wasRead: Bool, hasMention: Bool, expiresInSeconds: TimeInterval?, expiresStartedAtMs: Double?, linkPreviewUrl: String?, openGroupServerMessageId: Int64?, openGroupWhisperMods: Bool, openGroupWhisperTo: String? ) { self.id = id self.serverHash = serverHash self.messageUuid = messageUuid self.threadId = threadId self.authorId = authorId self.variant = variant self.body = body self.timestampMs = timestampMs self.receivedAtTimestampMs = receivedAtTimestampMs self.wasRead = (wasRead || !variant.canBeUnread) self.hasMention = hasMention self.expiresInSeconds = expiresInSeconds self.expiresStartedAtMs = expiresStartedAtMs self.linkPreviewUrl = linkPreviewUrl self.openGroupServerMessageId = openGroupServerMessageId self.openGroupWhisperMods = openGroupWhisperMods self.openGroupWhisperTo = openGroupWhisperTo } public init( serverHash: String? = nil, messageUuid: String? = nil, threadId: String, authorId: String, variant: Variant, body: String? = nil, timestampMs: Int64 = 0, wasRead: Bool = false, hasMention: Bool = false, expiresInSeconds: TimeInterval? = nil, expiresStartedAtMs: Double? = nil, linkPreviewUrl: String? = nil, openGroupServerMessageId: Int64? = nil, openGroupWhisperMods: Bool = false, openGroupWhisperTo: String? = nil ) { self.serverHash = serverHash self.messageUuid = messageUuid self.threadId = threadId self.authorId = authorId self.variant = variant self.body = body self.timestampMs = timestampMs self.receivedAtTimestampMs = { switch variant { case .standardIncoming, .standardOutgoing: return SnodeAPI.currentOffsetTimestampMs() /// For TSInteractions which are not `standardIncoming` and `standardOutgoing` use the `timestampMs` value default: return timestampMs } }() self.wasRead = (wasRead || !variant.canBeUnread) self.hasMention = hasMention self.expiresInSeconds = expiresInSeconds self.expiresStartedAtMs = expiresStartedAtMs self.linkPreviewUrl = linkPreviewUrl self.openGroupServerMessageId = openGroupServerMessageId self.openGroupWhisperMods = openGroupWhisperMods self.openGroupWhisperTo = openGroupWhisperTo } // MARK: - Custom Database Interaction public mutating func willInsert(_ db: Database) throws { // Automatically mark interactions which can't be unread as read so the unread count // isn't impacted self.wasRead = (self.wasRead || !self.variant.canBeUnread) } public func aroundInsert(_ db: Database, insert: () throws -> InsertionSuccess) throws { let success: InsertionSuccess = try insert() guard let threadVariant: SessionThread.Variant = try? SessionThread .filter(id: threadId) .select(.variant) .asRequest(of: SessionThread.Variant.self) .fetchOne(db) else { SNLog("Inserted an interaction but couldn't find it's associated thead") return } switch variant { case .standardOutgoing: // New outgoing messages should immediately determine their recipient list // from current thread state switch threadVariant { case .contact: try RecipientState( interactionId: success.rowID, recipientId: threadId, // Will be the contact id state: .sending ).insert(db) case .legacyGroup, .group: let closedGroupMemberIds: Set = (try? GroupMember .select(.profileId) .filter(GroupMember.Columns.groupId == threadId) .asRequest(of: String.self) .fetchSet(db)) .defaulting(to: []) guard !closedGroupMemberIds.isEmpty else { SNLog("Inserted an interaction but couldn't find it's associated thread members") return } // Exclude the current user when creating recipient states (as they will never // receive the message resulting in the message getting flagged as failed) let userPublicKey: String = getUserHexEncodedPublicKey(db) try closedGroupMemberIds .filter { memberId -> Bool in memberId != userPublicKey } .forEach { memberId in try RecipientState( interactionId: success.rowID, recipientId: memberId, state: .sending ).insert(db) } case .community: // Since we use the 'RecipientState' type to manage the message state // we need to ensure we have a state for all threads; so for open groups // we just use the open group id as the 'recipientId' value try RecipientState( interactionId: success.rowID, recipientId: threadId, // Will be the open group id state: .sending ).insert(db) } default: break } } public mutating func didInsert(_ inserted: InsertionSuccess) { self.id = inserted.rowID } } // MARK: - Mutation public extension Interaction { func with( serverHash: String? = nil, authorId: String? = nil, body: String? = nil, timestampMs: Int64? = nil, wasRead: Bool? = nil, hasMention: Bool? = nil, expiresInSeconds: TimeInterval? = nil, expiresStartedAtMs: Double? = nil, openGroupServerMessageId: Int64? = nil ) -> Interaction { return Interaction( id: self.id, serverHash: (serverHash ?? self.serverHash), messageUuid: self.messageUuid, threadId: self.threadId, authorId: (authorId ?? self.authorId), variant: self.variant, body: (body ?? self.body), timestampMs: (timestampMs ?? self.timestampMs), receivedAtTimestampMs: self.receivedAtTimestampMs, wasRead: ((wasRead ?? self.wasRead) || !self.variant.canBeUnread), hasMention: (hasMention ?? self.hasMention), expiresInSeconds: (expiresInSeconds ?? self.expiresInSeconds), expiresStartedAtMs: (expiresStartedAtMs ?? self.expiresStartedAtMs), linkPreviewUrl: self.linkPreviewUrl, openGroupServerMessageId: (openGroupServerMessageId ?? self.openGroupServerMessageId), openGroupWhisperMods: self.openGroupWhisperMods, openGroupWhisperTo: self.openGroupWhisperTo ) } } // MARK: - GRDB Interactions public extension Interaction { /// This will update the `wasRead` state the the interaction /// /// - Parameters /// - interactionId: The id of the specific interaction to mark as read /// - threadId: The id of the thread the interaction belongs to /// - includingOlder: Setting this to `true` will updated the `wasRead` flag for all older interactions as well /// - trySendReadReceipt: Setting this to `true` will schedule a `ReadReceiptJob` static func markAsRead( _ db: Database, interactionId: Int64?, threadId: String, threadVariant: SessionThread.Variant, includingOlder: Bool, trySendReadReceipt: Bool ) throws { guard let interactionId: Int64 = interactionId else { return } struct InteractionReadInfo: Decodable, FetchableRecord { let id: Int64 let variant: Interaction.Variant let timestampMs: Int64 let wasRead: Bool } // Once all of the below is done schedule the jobs func scheduleJobs( _ db: Database, threadId: String, threadVariant: SessionThread.Variant, interactionInfo: [InteractionReadInfo], lastReadTimestampMs: Int64 ) throws { // Update the last read timestamp if needed try SessionUtil.syncThreadLastReadIfNeeded( db, threadId: threadId, threadVariant: threadVariant, lastReadTimestampMs: lastReadTimestampMs ) // Add the 'DisappearingMessagesJob' if needed - this will update any expiring // messages `expiresStartedAtMs` values JobRunner.upsert( db, job: DisappearingMessagesJob.updateNextRunIfNeeded( db, interactionIds: interactionInfo.map { $0.id }, startedAtMs: TimeInterval(SnodeAPI.currentOffsetTimestampMs()) ) ) // Clear out any notifications for the interactions we mark as read Environment.shared?.notificationsManager.wrappedValue?.cancelNotifications( identifiers: interactionInfo .map { interactionInfo in Interaction.notificationIdentifier( for: interactionInfo.id, threadId: threadId, shouldGroupMessagesForThread: false ) } .appending(Interaction.notificationIdentifier( for: 0, threadId: threadId, shouldGroupMessagesForThread: true )) ) // If we want to send read receipts and it's a contact thread then try to add the // 'SendReadReceiptsJob' for and unread messages that weren't outgoing if trySendReadReceipt && threadVariant == .contact { JobRunner.upsert( db, job: SendReadReceiptsJob.createOrUpdateIfNeeded( db, threadId: threadId, interactionIds: interactionInfo .filter { !$0.wasRead && $0.variant != .standardOutgoing } .map { $0.id } ) ) } } // Since there is no guarantee on the order messages are inserted into the database // fetch the timestamp for the interaction and set everything before that as read let maybeInteractionInfo: InteractionReadInfo? = try Interaction .select(.id, .variant, .timestampMs, .wasRead) .filter(id: interactionId) .asRequest(of: InteractionReadInfo.self) .fetchOne(db) // If we aren't including older interactions then update and save the current one guard includingOlder, let interactionInfo: InteractionReadInfo = maybeInteractionInfo else { // Only mark as read and trigger the subsequent jobs if the interaction is // actually not read (no point updating and triggering db changes otherwise) guard maybeInteractionInfo?.wasRead == false, let timestampMs: Int64 = maybeInteractionInfo?.timestampMs, let variant: Variant = try Interaction .filter(id: interactionId) .select(.variant) .asRequest(of: Variant.self) .fetchOne(db) else { return } _ = try Interaction .filter(id: interactionId) .updateAll(db, Columns.wasRead.set(to: true)) try scheduleJobs( db, threadId: threadId, threadVariant: threadVariant, interactionInfo: [ InteractionReadInfo( id: interactionId, variant: variant, timestampMs: 0, wasRead: false ) ], lastReadTimestampMs: timestampMs ) return } let interactionQuery = Interaction .filter(Interaction.Columns.threadId == threadId) .filter(Interaction.Columns.timestampMs <= interactionInfo.timestampMs) .filter(Interaction.Columns.wasRead == false) let interactionInfoToMarkAsRead: [InteractionReadInfo] = try interactionQuery .select(.id, .variant, .timestampMs, .wasRead) .asRequest(of: InteractionReadInfo.self) .fetchAll(db) // If there are no other interactions to mark as read then just schedule the jobs // for this interaction (need to ensure the disapeparing messages run for sync'ed // outgoing messages which will always have 'wasRead' as false) guard !interactionInfoToMarkAsRead.isEmpty else { try scheduleJobs( db, threadId: threadId, threadVariant: threadVariant, interactionInfo: [interactionInfo], lastReadTimestampMs: interactionInfo.timestampMs ) return } // Update the `wasRead` flag to true try interactionQuery.updateAll(db, Columns.wasRead.set(to: true)) // Retrieve the interaction ids we want to update try scheduleJobs( db, threadId: threadId, threadVariant: threadVariant, interactionInfo: interactionInfoToMarkAsRead, lastReadTimestampMs: interactionInfo.timestampMs ) } /// This method flags sent messages as read for the specified recipients /// /// **Note:** This method won't update the 'wasRead' flag (it will be updated via the above method) @discardableResult static func markAsRead( _ db: Database, recipientId: String, timestampMsValues: [Int64], readTimestampMs: Int64 ) throws -> Set { guard db[.areReadReceiptsEnabled] == true else { return [] } // Update the read state let rowIds: [Int64] = try RecipientState .select(Column.rowID) .filter(RecipientState.Columns.recipientId == recipientId) .joining( required: RecipientState.interaction .filter(timestampMsValues.contains(Columns.timestampMs)) .filter(Columns.variant == Variant.standardOutgoing) ) .asRequest(of: Int64.self) .fetchAll(db) // If there were no 'rowIds' then no need to run the below queries, all of the timestamps // and for pending read receipts guard !rowIds.isEmpty else { return timestampMsValues.asSet() } // Update the 'readTimestampMs' if it doesn't match (need to do this to prevent // the UI update from being triggered for a redundant update) try RecipientState .filter(rowIds.contains(Column.rowID)) .filter(RecipientState.Columns.readTimestampMs == nil) .updateAll( db, RecipientState.Columns.readTimestampMs.set(to: readTimestampMs) ) // If the message still appeared to be sending then mark it as sent try RecipientState .filter(rowIds.contains(Column.rowID)) .filter(RecipientState.Columns.state == RecipientState.State.sending) .updateAll( db, RecipientState.Columns.state.set(to: RecipientState.State.sent) ) // Retrieve the set of timestamps which were updated let timestampsUpdated: Set = try Interaction .select(Columns.timestampMs) .filter(timestampMsValues.contains(Columns.timestampMs)) .filter(Columns.variant == Variant.standardOutgoing) .joining( required: Interaction.recipientStates .filter(rowIds.contains(Column.rowID)) ) .asRequest(of: Int64.self) .fetchSet(db) // Return the timestamps which weren't updated return timestampMsValues .asSet() .subtracting(timestampsUpdated) } } // MARK: - Search Queries public extension Interaction { struct TimestampInfo: FetchableRecord, Codable { public let id: Int64 public let timestampMs: Int64 public init( id: Int64, timestampMs: Int64 ) { self.id = id self.timestampMs = timestampMs } } static func idsForTermWithin(threadId: String, pattern: FTS5Pattern) -> SQLRequest { let interaction: TypedTableAlias = TypedTableAlias() let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName) let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) let request: SQLRequest = """ SELECT \(interaction[.id]), \(interaction[.timestampMs]) FROM \(Interaction.self) JOIN \(interactionFullTextSearch) ON ( \(interactionFullTextSearch).rowid = \(interaction.alias[Column.rowID]) AND \(SQL("\(interactionFullTextSearch).\(threadIdLiteral) = \(threadId)")) AND \(interactionFullTextSearch).\(SQL(stringLiteral: Interaction.Columns.body.name)) MATCH \(pattern) ) ORDER BY \(interaction[.timestampMs].desc) """ return request } } // MARK: - Convenience public extension Interaction { static let oversizeTextMessageSizeThreshold: UInt = (2 * 1024) // MARK: - Variables var isExpiringMessage: Bool { guard variant == .standardIncoming || variant == .standardOutgoing else { return false } return (expiresInSeconds ?? 0 > 0) } var openGroupWhisper: Bool { return (openGroupWhisperMods || (openGroupWhisperTo != nil)) } var notificationIdentifiers: [String] { [ notificationIdentifier(shouldGroupMessagesForThread: true), notificationIdentifier(shouldGroupMessagesForThread: false) ] } // MARK: - Functions func notificationIdentifier(shouldGroupMessagesForThread: Bool) -> String { // When the app is in the background we want the notifications to be grouped to prevent spam return Interaction.notificationIdentifier( for: (id ?? 0), threadId: threadId, shouldGroupMessagesForThread: shouldGroupMessagesForThread ) } fileprivate static func notificationIdentifier(for id: Int64, threadId: String, shouldGroupMessagesForThread: Bool) -> String { // When the app is in the background we want the notifications to be grouped to prevent spam guard !shouldGroupMessagesForThread else { return threadId } return "\(threadId)-\(id)" } func markingAsDeleted() -> Interaction { return Interaction( id: id, serverHash: nil, messageUuid: messageUuid, threadId: threadId, authorId: authorId, variant: .standardIncomingDeleted, body: nil, timestampMs: timestampMs, receivedAtTimestampMs: receivedAtTimestampMs, wasRead: (wasRead || !Variant.standardIncomingDeleted.canBeUnread), hasMention: false, expiresInSeconds: expiresInSeconds, expiresStartedAtMs: expiresStartedAtMs, linkPreviewUrl: nil, openGroupServerMessageId: openGroupServerMessageId, openGroupWhisperMods: openGroupWhisperMods, openGroupWhisperTo: openGroupWhisperTo ) } static func isUserMentioned( _ db: Database, threadId: String, body: String?, quoteAuthorId: String? = nil, using dependencies: Dependencies = Dependencies() ) -> Bool { var publicKeysToCheck: [String] = [ getUserHexEncodedPublicKey(db, using: dependencies) ] // If the thread is an open group then add the blinded id as a key to check if let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: threadId) { if let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), let blindedKeyPair: KeyPair = dependencies.crypto.generate( .blindedKeyPair(serverPublicKey: openGroup.publicKey, edKeyPair: userEd25519KeyPair, using: dependencies) ) { publicKeysToCheck.append(SessionId(.blinded15, publicKey: blindedKeyPair.publicKey).hexString) publicKeysToCheck.append(SessionId(.blinded25, publicKey: blindedKeyPair.publicKey).hexString) } } return isUserMentioned( publicKeysToCheck: publicKeysToCheck, body: body, quoteAuthorId: quoteAuthorId ) } static func isUserMentioned( publicKeysToCheck: [String], body: String?, quoteAuthorId: String? = nil ) -> Bool { // A user is mentioned if their public key is in the body of a message or one of their messages // was quoted return publicKeysToCheck.contains { publicKey in ( body != nil && (body ?? "").contains("@\(publicKey)") ) || ( quoteAuthorId == publicKey ) } } /// Use the `Interaction.previewText` method directly where possible rather than this method as it /// makes it's own database queries func previewText(_ db: Database) -> String { switch variant { case .standardIncoming, .standardOutgoing: return Interaction.previewText( variant: self.variant, body: self.body, attachmentDescriptionInfo: try? attachments .select(.id, .variant, .contentType, .sourceFilename) .asRequest(of: Attachment.DescriptionInfo.self) .fetchOne(db), attachmentCount: try? attachments.fetchCount(db), isOpenGroupInvitation: linkPreview .filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation) .isNotEmpty(db) ) case .infoMediaSavedNotification, .infoScreenshotNotification, .infoCall: // Note: These should only occur in 'contact' threads so the `threadId` // is the contact id return Interaction.previewText( variant: self.variant, body: self.body, authorDisplayName: Profile.displayName(db, id: threadId) ) default: return Interaction.previewText( variant: self.variant, body: self.body ) } } /// This menthod generates the preview text for a given transaction static func previewText( variant: Variant, body: String?, threadContactDisplayName: String = "", authorDisplayName: String = "", attachmentDescriptionInfo: Attachment.DescriptionInfo? = nil, attachmentCount: Int? = nil, isOpenGroupInvitation: Bool = false ) -> String { switch variant { case .standardIncomingDeleted: return "" case .standardIncoming, .standardOutgoing: let attachmentDescription: String? = Attachment.description( for: attachmentDescriptionInfo, count: attachmentCount ) if let attachmentDescription: String = attachmentDescription, let body: String = body, !attachmentDescription.isEmpty, !body.isEmpty { if CurrentAppContext().isRTL { return "\(body): \(attachmentDescription)" } return "\(attachmentDescription): \(body)" } if let body: String = body, !body.isEmpty { return body } if let attachmentDescription: String = attachmentDescription, !attachmentDescription.isEmpty { return attachmentDescription } if isOpenGroupInvitation { return "😎 Open group invitation" } // TODO: We should do better here return "" case .infoMediaSavedNotification: // TODO: Use referencedAttachmentTimestamp to tell the user * which * media was saved return String(format: "media_saved".localized(), authorDisplayName) case .infoScreenshotNotification: return String(format: "screenshot_taken".localized(), authorDisplayName) case .infoClosedGroupCreated: return "GROUP_CREATED".localized() case .infoClosedGroupCurrentUserLeft: return "GROUP_YOU_LEFT".localized() case .infoClosedGroupCurrentUserLeaving: return "group_you_leaving".localized() case .infoClosedGroupCurrentUserErrorLeaving: return "group_unable_to_leave".localized() case .infoClosedGroupUpdated: return (body ?? "GROUP_UPDATED".localized()) case .infoMessageRequestAccepted: return (body ?? "MESSAGE_REQUESTS_ACCEPTED".localized()) case .infoDisappearingMessagesUpdate: guard let infoMessageData: Data = (body ?? "").data(using: .utf8), let messageInfo: DisappearingMessagesConfiguration.MessageInfo = try? JSONDecoder().decode( DisappearingMessagesConfiguration.MessageInfo.self, from: infoMessageData ) else { return (body ?? "") } return messageInfo.previewText case .infoCall: guard let infoMessageData: Data = (body ?? "").data(using: .utf8), let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( CallMessage.MessageInfo.self, from: infoMessageData ) else { return (body ?? "") } return messageInfo.previewText(threadContactDisplayName: threadContactDisplayName) } } func state(_ db: Database) throws -> RecipientState.State { let states: [RecipientState.State] = try RecipientState.State .fetchAll( db, recipientStates.select(.state) ) return Interaction.state(for: states) } static func state(for states: [RecipientState.State]) -> RecipientState.State { // If there are no states then assume this is a new interaction which hasn't been // saved yet so has no states guard !states.isEmpty else { return .sending } var hasFailed: Bool = false for state in states { switch state { // If there are any "sending" recipients, consider this message "sending" case .sending: return .sending case .failed: hasFailed = true break default: break } } // If there are any "failed" recipients, consider this message "failed" guard !hasFailed else { return .failed } // Otherwise, consider the message "sent" // // Note: This includes messages with no recipients return .sent } }