// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB import SessionUtilitiesKit public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "thread" } public static let contact = hasOne(Contact.self, using: Contact.threadForeignKey) public static let closedGroup = hasOne(ClosedGroup.self, using: ClosedGroup.threadForeignKey) public static let openGroup = hasOne(OpenGroup.self, using: OpenGroup.threadForeignKey) private static let disappearingMessagesConfiguration = hasOne( DisappearingMessagesConfiguration.self, using: DisappearingMessagesConfiguration.threadForeignKey ) public static let interactions = hasMany(Interaction.self, using: Interaction.threadForeignKey) public static let typingIndicator = hasOne( ThreadTypingIndicator.self, using: ThreadTypingIndicator.threadForeignKey ) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case id case variant case creationDateTimestamp case shouldBeVisible case isPinned case messageDraft case notificationSound case mutedUntilTimestamp case onlyNotifyForMentions } public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { case contact case closedGroup case openGroup } /// Unique identifier for a thread (formerly known as uniqueId) /// /// This value will depend on the variant: /// **contact:** The contact id /// **closedGroup:** The closed group public key /// **openGroup:** The `\(server.lowercased()).\(room)` value public let id: String /// Enum indicating what type of thread this is public let variant: Variant /// A timestamp indicating when this thread was created public let creationDateTimestamp: TimeInterval /// A flag indicating whether the thread should be visible public let shouldBeVisible: Bool /// A flag indicating whether the thread is pinned public let isPinned: Bool /// The value the user started entering into the input field before they left the conversation screen public let messageDraft: String? /// The sound which should be used when receiving a notification for this thread /// /// **Note:** If unset this will use the `Preferences.Sound.defaultNotificationSound` public let notificationSound: Preferences.Sound? /// Timestamp (seconds since epoch) for when this thread should stop being muted public let mutedUntilTimestamp: TimeInterval? /// A flag indicating whether the thread should only notify for mentions public let onlyNotifyForMentions: Bool // MARK: - Relationships public var contact: QueryInterfaceRequest { request(for: SessionThread.contact) } public var closedGroup: QueryInterfaceRequest { request(for: SessionThread.closedGroup) } public var openGroup: QueryInterfaceRequest { request(for: SessionThread.openGroup) } public var disappearingMessagesConfiguration: QueryInterfaceRequest { request(for: SessionThread.disappearingMessagesConfiguration) } public var interactions: QueryInterfaceRequest { request(for: SessionThread.interactions) } public var typingIndicator: QueryInterfaceRequest { request(for: SessionThread.typingIndicator) } // MARK: - Initialization public init( id: String, variant: Variant, creationDateTimestamp: TimeInterval = Date().timeIntervalSince1970, shouldBeVisible: Bool = false, isPinned: Bool = false, messageDraft: String? = nil, notificationSound: Preferences.Sound? = nil, mutedUntilTimestamp: TimeInterval? = nil, onlyNotifyForMentions: Bool = false ) { self.id = id self.variant = variant self.creationDateTimestamp = creationDateTimestamp self.shouldBeVisible = shouldBeVisible self.isPinned = isPinned self.messageDraft = messageDraft self.notificationSound = notificationSound self.mutedUntilTimestamp = mutedUntilTimestamp self.onlyNotifyForMentions = onlyNotifyForMentions } // MARK: - Custom Database Interaction public func insert(_ db: Database) throws { try performInsert(db) db[.hasSavedThread] = true } } // MARK: - Mutation public extension SessionThread { func with( shouldBeVisible: Bool? = nil, isPinned: Bool? = nil ) -> SessionThread { return SessionThread( id: id, variant: variant, creationDateTimestamp: creationDateTimestamp, shouldBeVisible: (shouldBeVisible ?? self.shouldBeVisible), isPinned: (isPinned ?? self.isPinned), messageDraft: messageDraft, notificationSound: notificationSound, mutedUntilTimestamp: mutedUntilTimestamp, onlyNotifyForMentions: onlyNotifyForMentions ) } } // MARK: - GRDB Interactions public extension SessionThread { /// Fetches or creates a SessionThread with the specified id and variant /// /// **Notes:** /// - The `variant` will be ignored if an existing thread is found /// - This method **will** save the newly created SessionThread to the database static func fetchOrCreate(_ db: Database, id: ID, variant: Variant) throws -> SessionThread { guard let existingThread: SessionThread = try? fetchOne(db, id: id) else { return try SessionThread(id: id, variant: variant) .saved(db) } return existingThread } func isMessageRequest(_ db: Database, includeNonVisible: Bool = false) -> Bool { return ( (includeNonVisible || shouldBeVisible) && variant == .contact && id != getUserHexEncodedPublicKey(db) && // Note to self (try? Contact .filter(id: id) .select(.isApproved) .asRequest(of: Bool.self) .fetchOne(db)) .defaulting(to: false) == false ) } } // MARK: - Convenience public extension SessionThread { static func messageRequestsQuery(userPublicKey: String, includeNonVisible: Bool = false) -> SQLRequest { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() return """ SELECT \(thread.allColumns()) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) WHERE ( \(SessionThread.isMessageRequest(userPublicKey: userPublicKey, includeNonVisible: includeNonVisible)) ) """ } static func unreadMessageRequestsThreadIdQuery(userPublicKey: String, includeNonVisible: Bool = false) -> SQLRequest { let thread: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() return """ SELECT \(thread[.id]) FROM \(SessionThread.self) JOIN \(Interaction.self) ON ( \(interaction[.threadId]) = \(thread[.id]) AND \(interaction[.wasRead]) = false ) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) WHERE ( \(SessionThread.isMessageRequest(userPublicKey: userPublicKey, includeNonVisible: includeNonVisible)) ) GROUP BY \(thread[.id]) """ } /// This method can be used to filter a thread query to only include messages requests /// /// **Note:** In order to use this filter you **MUST** have a `joining(required/optional:)` to the /// `SessionThread.contact` association or it won't work static func isMessageRequest(userPublicKey: String, includeNonVisible: Bool = false) -> SQLSpecificExpressible { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let shouldBeVisibleSQL: SQL = (includeNonVisible ? SQL(stringLiteral: "true") : SQL("\(thread[.shouldBeVisible]) = true") ) return SQL( """ \(shouldBeVisibleSQL) AND \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND \(SQL("\(thread[.id]) != \(userPublicKey)")) AND IFNULL(\(contact[.isApproved]), false) = false """ ) } func isNoteToSelf(_ db: Database? = nil) -> Bool { return ( variant == .contact && id == getUserHexEncodedPublicKey(db) ) } func shouldShowNotification(_ db: Database, for interaction: Interaction, isMessageRequest: Bool) -> Bool { // Ensure that the thread isn't muted and either the thread isn't only notifying for mentions // or the user was actually mentioned guard Date().timeIntervalSince1970 > (self.mutedUntilTimestamp ?? 0) && ( self.variant == .contact || !self.onlyNotifyForMentions || interaction.hasMention ) else { return false } let userPublicKey: String = getUserHexEncodedPublicKey(db) // No need to notify the user for self-send messages guard interaction.authorId != userPublicKey else { return false } // If the thread is a message request then we only want to notify for the first message if self.variant == .contact && isMessageRequest { let hasHiddenMessageRequests: Bool = db[.hasHiddenMessageRequests] // If the user hasn't hidden the message requests section then only show the notification if // all the other message request threads have been read if !hasHiddenMessageRequests { let numUnreadMessageRequestThreads: Int = (try? SessionThread .unreadMessageRequestsThreadIdQuery(userPublicKey: userPublicKey, includeNonVisible: true) .fetchCount(db)) .defaulting(to: 1) guard numUnreadMessageRequestThreads == 1 else { return false } } // We only want to show a notification for the first interaction in the thread guard ((try? self.interactions.fetchCount(db)) ?? 0) <= 1 else { return false } // Need to re-show the message requests section if it had been hidden if hasHiddenMessageRequests { db[.hasHiddenMessageRequests] = false } } return true } static func displayName( threadId: String, variant: Variant, closedGroupName: String? = nil, openGroupName: String? = nil, isNoteToSelf: Bool = false, profile: Profile? = nil ) -> String { switch variant { case .closedGroup: return (closedGroupName ?? "Unknown Group") case .openGroup: return (openGroupName ?? "Unknown Group") case .contact: guard !isNoteToSelf else { return "NOTE_TO_SELF".localized() } guard let profile: Profile = profile else { return Profile.truncated(id: threadId, truncating: .middle) } return profile.displayName() } } } // MARK: - Objective-C Support // FIXME: Remove when possible @objc(SMKThread) public class SMKThread: NSObject { @objc(deleteAll) public static func deleteAll() { Storage.shared.writeAsync { db in _ = try SessionThread.deleteAll(db) } } @objc(isThreadMuted:) public static func isThreadMuted(_ threadId: String) -> Bool { return Storage.shared.read { db in let mutedUntilTimestamp: TimeInterval? = try SessionThread .select(SessionThread.Columns.mutedUntilTimestamp) .filter(id: threadId) .asRequest(of: TimeInterval?.self) .fetchOne(db) return (mutedUntilTimestamp != nil) } .defaulting(to: false) } @objc(isOnlyNotifyingForMentions:) public static func isOnlyNotifyingForMentions(_ threadId: String) -> Bool { return Storage.shared.read { db in return try SessionThread .select(SessionThread.Columns.onlyNotifyForMentions == true) .filter(id: threadId) .asRequest(of: Bool.self) .fetchOne(db) } .defaulting(to: false) } @objc(setIsOnlyNotifyingForMentions:to:) public static func isOnlyNotifyingForMentions(_ threadId: String, isEnabled: Bool) { Storage.shared.write { db in try SessionThread .filter(id: threadId) .updateAll(db, SessionThread.Columns.onlyNotifyForMentions.set(to: isEnabled)) } } @objc(mutedUntilDateFor:) public static func mutedUntilDateFor(_ threadId: String) -> Date? { return Storage.shared.read { db in return try SessionThread .select(SessionThread.Columns.mutedUntilTimestamp) .filter(id: threadId) .asRequest(of: TimeInterval.self) .fetchOne(db) } .map { Date(timeIntervalSince1970: $0) } } @objc(updateWithMutedUntilDateTo:forThreadId:) public static func updateWithMutedUntilDate(to date: Date?, threadId: String) { Storage.shared.write { db in try SessionThread .filter(id: threadId) .updateAll(db, SessionThread.Columns.mutedUntilTimestamp.set(to: date?.timeIntervalSince1970)) } } }