session-ios/SessionMessagingKit/Database/Models/SessionThread.swift

388 lines
14 KiB
Swift
Raw Normal View History

// 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<Contact> {
request(for: SessionThread.contact)
}
public var closedGroup: QueryInterfaceRequest<ClosedGroup> {
request(for: SessionThread.closedGroup)
}
public var openGroup: QueryInterfaceRequest<OpenGroup> {
request(for: SessionThread.openGroup)
}
public var disappearingMessagesConfiguration: QueryInterfaceRequest<DisappearingMessagesConfiguration> {
request(for: SessionThread.disappearingMessagesConfiguration)
}
public var interactions: QueryInterfaceRequest<Interaction> {
request(for: SessionThread.interactions)
}
public var typingIndicator: QueryInterfaceRequest<ThreadTypingIndicator> {
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
Fixed a number of reported bugs, some cleanup, added animated profile support Added support for animated profile images (no ability to crop/resize) Updated the message trimming to only remove messages if the open group has 2000 messages or more Updated the message trimming setting to default to be on Updated the ContextMenu to fade out the snapshot as well (looked buggy if the device had even minor lag) Updated the ProfileManager to delete and re-download invalid avatar images (and updated the conversation screen to reload when avatars complete downloading) Updated the message request notification logic so it will show notifications when receiving a new message request as long as the user has read all the old ones (previously the user had to accept/reject all the old ones) Fixed a bug where the "trim open group messages" toggle was accessing UI off the main thread Fixed a bug where the "Chats" settings screen had a close button instead of a back button Fixed a bug where the 'viewsToMove' for the reply UI was inconsistent in some places Fixed an issue where the ProfileManager was doing all of it's validation (and writing to disk) within the database write closure which would block database writes unnecessarily Fixed a bug where a message request wouldn't be identified as such just because it wasn't visible in the conversations list Fixed a bug where opening a message request notification would result in the message request being in the wrong state (also wouldn't insert the 'MessageRequestsViewController' into the hierarchy) Fixed a bug where the avatar image wouldn't appear beside incoming closed group message in some situations cases Removed an error log that was essentially just spam Remove the logic to delete old profile images when calling save on a Profile (wouldn't get called if the row was modified directly and duplicates GarbageCollection logic) Remove the logic to send a notification when calling save on a Profile (wouldn't get called if the row was modified directly) Tweaked the message trimming description to be more accurate Cleaned up some duplicate logic used to determine if a notification should be shown Cleaned up some onion request logic (was passing the version info in some cases when not needed) Moved the push notification notify API call into the PushNotificationAPI class for consistency
2022-07-08 09:53:48 +02:00
(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<SessionThread> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
return """
SELECT \(thread.allColumns())
FROM \(SessionThread.self)
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])
WHERE (
\(SessionThread.isMessageRequest(userPublicKey: userPublicKey, includeNonVisible: includeNonVisible))
)
"""
}
Fixed a number of reported bugs, some cleanup, added animated profile support Added support for animated profile images (no ability to crop/resize) Updated the message trimming to only remove messages if the open group has 2000 messages or more Updated the message trimming setting to default to be on Updated the ContextMenu to fade out the snapshot as well (looked buggy if the device had even minor lag) Updated the ProfileManager to delete and re-download invalid avatar images (and updated the conversation screen to reload when avatars complete downloading) Updated the message request notification logic so it will show notifications when receiving a new message request as long as the user has read all the old ones (previously the user had to accept/reject all the old ones) Fixed a bug where the "trim open group messages" toggle was accessing UI off the main thread Fixed a bug where the "Chats" settings screen had a close button instead of a back button Fixed a bug where the 'viewsToMove' for the reply UI was inconsistent in some places Fixed an issue where the ProfileManager was doing all of it's validation (and writing to disk) within the database write closure which would block database writes unnecessarily Fixed a bug where a message request wouldn't be identified as such just because it wasn't visible in the conversations list Fixed a bug where opening a message request notification would result in the message request being in the wrong state (also wouldn't insert the 'MessageRequestsViewController' into the hierarchy) Fixed a bug where the avatar image wouldn't appear beside incoming closed group message in some situations cases Removed an error log that was essentially just spam Remove the logic to delete old profile images when calling save on a Profile (wouldn't get called if the row was modified directly and duplicates GarbageCollection logic) Remove the logic to send a notification when calling save on a Profile (wouldn't get called if the row was modified directly) Tweaked the message trimming description to be more accurate Cleaned up some duplicate logic used to determine if a notification should be shown Cleaned up some onion request logic (was passing the version info in some cases when not needed) Moved the push notification notify API call into the PushNotificationAPI class for consistency
2022-07-08 09:53:48 +02:00
static func unreadMessageRequestsThreadIdQuery(userPublicKey: String, includeNonVisible: Bool = false) -> SQLRequest<String> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = 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 (
Fixed a number of reported bugs, some cleanup, added animated profile support Added support for animated profile images (no ability to crop/resize) Updated the message trimming to only remove messages if the open group has 2000 messages or more Updated the message trimming setting to default to be on Updated the ContextMenu to fade out the snapshot as well (looked buggy if the device had even minor lag) Updated the ProfileManager to delete and re-download invalid avatar images (and updated the conversation screen to reload when avatars complete downloading) Updated the message request notification logic so it will show notifications when receiving a new message request as long as the user has read all the old ones (previously the user had to accept/reject all the old ones) Fixed a bug where the "trim open group messages" toggle was accessing UI off the main thread Fixed a bug where the "Chats" settings screen had a close button instead of a back button Fixed a bug where the 'viewsToMove' for the reply UI was inconsistent in some places Fixed an issue where the ProfileManager was doing all of it's validation (and writing to disk) within the database write closure which would block database writes unnecessarily Fixed a bug where a message request wouldn't be identified as such just because it wasn't visible in the conversations list Fixed a bug where opening a message request notification would result in the message request being in the wrong state (also wouldn't insert the 'MessageRequestsViewController' into the hierarchy) Fixed a bug where the avatar image wouldn't appear beside incoming closed group message in some situations cases Removed an error log that was essentially just spam Remove the logic to delete old profile images when calling save on a Profile (wouldn't get called if the row was modified directly and duplicates GarbageCollection logic) Remove the logic to send a notification when calling save on a Profile (wouldn't get called if the row was modified directly) Tweaked the message trimming description to be more accurate Cleaned up some duplicate logic used to determine if a notification should be shown Cleaned up some onion request logic (was passing the version info in some cases when not needed) Moved the push notification notify API call into the PushNotificationAPI class for consistency
2022-07-08 09:53:48 +02:00
\(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<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = 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)
)
}
Fixed a number of reported bugs, some cleanup, added animated profile support Added support for animated profile images (no ability to crop/resize) Updated the message trimming to only remove messages if the open group has 2000 messages or more Updated the message trimming setting to default to be on Updated the ContextMenu to fade out the snapshot as well (looked buggy if the device had even minor lag) Updated the ProfileManager to delete and re-download invalid avatar images (and updated the conversation screen to reload when avatars complete downloading) Updated the message request notification logic so it will show notifications when receiving a new message request as long as the user has read all the old ones (previously the user had to accept/reject all the old ones) Fixed a bug where the "trim open group messages" toggle was accessing UI off the main thread Fixed a bug where the "Chats" settings screen had a close button instead of a back button Fixed a bug where the 'viewsToMove' for the reply UI was inconsistent in some places Fixed an issue where the ProfileManager was doing all of it's validation (and writing to disk) within the database write closure which would block database writes unnecessarily Fixed a bug where a message request wouldn't be identified as such just because it wasn't visible in the conversations list Fixed a bug where opening a message request notification would result in the message request being in the wrong state (also wouldn't insert the 'MessageRequestsViewController' into the hierarchy) Fixed a bug where the avatar image wouldn't appear beside incoming closed group message in some situations cases Removed an error log that was essentially just spam Remove the logic to delete old profile images when calling save on a Profile (wouldn't get called if the row was modified directly and duplicates GarbageCollection logic) Remove the logic to send a notification when calling save on a Profile (wouldn't get called if the row was modified directly) Tweaked the message trimming description to be more accurate Cleaned up some duplicate logic used to determine if a notification should be shown Cleaned up some onion request logic (was passing the version info in some cases when not needed) Moved the push notification notify API call into the PushNotificationAPI class for consistency
2022-07-08 09:53:48 +02:00
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))
}
}
}