mirror of https://github.com/oxen-io/session-ios
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
774 lines
30 KiB
Swift
774 lines
30 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import GRDB
|
|
import SessionUtilitiesKit
|
|
import SessionSnodeKit
|
|
|
|
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)
|
|
public 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
|
|
@available(*, deprecated, message: "use 'pinnedPriority > 0' instead") case isPinned
|
|
case messageDraft
|
|
case notificationSound
|
|
case mutedUntilTimestamp
|
|
case onlyNotifyForMentions
|
|
case markedAsUnread
|
|
case pinnedPriority
|
|
}
|
|
|
|
public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible, CaseIterable {
|
|
case contact
|
|
case legacyGroup
|
|
case community
|
|
case group
|
|
}
|
|
|
|
/// 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
|
|
@available(*, deprecated, message: "use 'pinnedPriority > 0' instead")
|
|
private let isPinned: Bool = false
|
|
|
|
/// 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
|
|
|
|
/// A flag indicating whether this thread has been manually marked as unread by the user
|
|
public let markedAsUnread: Bool?
|
|
|
|
/// A value indicating the priority of this conversation within the pinned conversations
|
|
public let pinnedPriority: Int32?
|
|
|
|
// 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,
|
|
shouldBeVisible: Bool = false,
|
|
isPinned: Bool = false,
|
|
messageDraft: String? = nil,
|
|
notificationSound: Preferences.Sound? = nil,
|
|
mutedUntilTimestamp: TimeInterval? = nil,
|
|
onlyNotifyForMentions: Bool = false,
|
|
markedAsUnread: Bool? = false,
|
|
pinnedPriority: Int32? = nil
|
|
) {
|
|
self.id = id
|
|
self.variant = variant
|
|
self.creationDateTimestamp = creationDateTimestamp
|
|
self.shouldBeVisible = shouldBeVisible
|
|
self.messageDraft = messageDraft
|
|
self.notificationSound = notificationSound
|
|
self.mutedUntilTimestamp = mutedUntilTimestamp
|
|
self.onlyNotifyForMentions = onlyNotifyForMentions
|
|
self.markedAsUnread = markedAsUnread
|
|
self.pinnedPriority = ((pinnedPriority ?? 0) > 0 ? pinnedPriority :
|
|
(isPinned ? 1 : 0)
|
|
)
|
|
}
|
|
|
|
// MARK: - Custom Database Interaction
|
|
|
|
public func willInsert(_ db: Database) throws {
|
|
db[.hasSavedThread] = true
|
|
}
|
|
}
|
|
|
|
// MARK: - GRDB Interactions
|
|
|
|
public extension SessionThread {
|
|
/// This type allows the specification of different `SessionThread` properties to use when creating/updating a thread, by default
|
|
/// it will attempt to use the values set in `libSession` if none are present
|
|
struct TargetValues {
|
|
public enum Value<T> {
|
|
case setTo(T)
|
|
case useLibSession
|
|
|
|
/// We should generally try to make `libSession` the source of truth for conversation settings (so they sync between
|
|
/// devices) but there are some cases where we don't want to modify a setting (eg. when handling a config change), so
|
|
/// this case can be used for those situations
|
|
case useExisting
|
|
|
|
var valueOrNull: T? {
|
|
switch self {
|
|
case .setTo(let value): return value
|
|
default: return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
let creationDateTimestamp: TimeInterval
|
|
let shouldBeVisible: Value<Bool>
|
|
let pinnedPriority: Value<Int32>
|
|
let disappearingMessagesConfig: Value<DisappearingMessagesConfiguration>
|
|
|
|
// MARK: - Convenience
|
|
|
|
public static var existingOrDefault: TargetValues {
|
|
return TargetValues(shouldBeVisible: .useLibSession)
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
|
|
public init(
|
|
creationDateTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000),
|
|
shouldBeVisible: Value<Bool>,
|
|
pinnedPriority: Value<Int32> = .useLibSession,
|
|
disappearingMessagesConfig: Value<DisappearingMessagesConfiguration> = .useLibSession
|
|
) {
|
|
self.creationDateTimestamp = creationDateTimestamp
|
|
self.shouldBeVisible = shouldBeVisible
|
|
self.pinnedPriority = pinnedPriority
|
|
self.disappearingMessagesConfig = disappearingMessagesConfig
|
|
}
|
|
}
|
|
|
|
/// Updates or inserts a `SessionThread` with the specified `id`, `variant` and specified `values`
|
|
///
|
|
/// **Note:** This method **will** save the newly created/updated `SessionThread` to the database
|
|
@discardableResult static func upsert(
|
|
_ db: Database,
|
|
id: ID,
|
|
variant: Variant,
|
|
values: TargetValues,
|
|
calledFromConfig configTriggeringChange: LibSession.Config.Variant?,
|
|
using dependencies: Dependencies
|
|
) throws -> SessionThread {
|
|
var result: SessionThread
|
|
|
|
/// If the thread doesn't already exist then create it (with the provided defaults)
|
|
switch try? fetchOne(db, id: id) {
|
|
case .some(let existingThread): result = existingThread
|
|
case .none:
|
|
let targetPriority: Int32 = LibSession.pinnedPriority(
|
|
db,
|
|
threadId: id,
|
|
threadVariant: variant,
|
|
conf: configTriggeringChange?.conf,
|
|
using: dependencies
|
|
)
|
|
|
|
result = try SessionThread(
|
|
id: id,
|
|
variant: variant,
|
|
creationDateTimestamp: values.creationDateTimestamp,
|
|
shouldBeVisible: LibSession.shouldBeVisible(priority: targetPriority),
|
|
pinnedPriority: targetPriority
|
|
).upserted(db)
|
|
}
|
|
|
|
/// Setup the `DisappearingMessagesConfiguration` as specified
|
|
switch (variant, values.disappearingMessagesConfig) {
|
|
case (.community, _), (_, .useExisting): break // No need to do anything
|
|
case (_, .setTo(let config)): // Save the explicit config
|
|
try config
|
|
.upserted(db)
|
|
.clearUnrelatedControlMessages(
|
|
db,
|
|
threadVariant: variant
|
|
)
|
|
|
|
case (_, .useLibSession): // Create and save the config from libSession
|
|
guard configTriggeringChange == nil else { throw LibSessionError.invalidConfigAccess }
|
|
|
|
try LibSession
|
|
.disappearingMessagesConfig(
|
|
db,
|
|
threadId: id,
|
|
threadVariant: variant,
|
|
conf: configTriggeringChange?.conf,
|
|
using: dependencies
|
|
)?
|
|
.upserted(db)
|
|
.clearUnrelatedControlMessages(
|
|
db,
|
|
threadVariant: variant
|
|
)
|
|
}
|
|
|
|
/// Apply any changes if the provided `values` don't match the current or default settings
|
|
var requiredChanges: [ConfigColumnAssignment] = []
|
|
var finalShouldBeVisible: Bool = result.shouldBeVisible
|
|
var finalPinnedPriority: Int32? = result.pinnedPriority
|
|
|
|
/// The `shouldBeVisible` flag is based on `pinnedPriority` so we need to check these two together if they
|
|
/// should both be sourced from `libSession`
|
|
switch (values.pinnedPriority, values.shouldBeVisible) {
|
|
case (.useLibSession, .useLibSession):
|
|
let targetPriority: Int32 = LibSession.pinnedPriority(
|
|
db,
|
|
threadId: id,
|
|
threadVariant: variant,
|
|
conf: configTriggeringChange?.conf,
|
|
using: dependencies
|
|
)
|
|
let libSessionShouldBeVisible: Bool = LibSession.shouldBeVisible(priority: targetPriority)
|
|
|
|
if targetPriority != result.pinnedPriority {
|
|
requiredChanges.append(SessionThread.Columns.pinnedPriority.set(to: targetPriority))
|
|
finalPinnedPriority = targetPriority
|
|
}
|
|
|
|
if libSessionShouldBeVisible != result.shouldBeVisible {
|
|
requiredChanges.append(SessionThread.Columns.shouldBeVisible.set(to: libSessionShouldBeVisible))
|
|
finalShouldBeVisible = libSessionShouldBeVisible
|
|
}
|
|
|
|
default: break
|
|
}
|
|
|
|
/// Otherwise we can just handle the explicit `setTo` cases for these
|
|
if case .setTo(let value) = values.pinnedPriority, value != result.pinnedPriority {
|
|
requiredChanges.append(SessionThread.Columns.pinnedPriority.set(to: value))
|
|
finalPinnedPriority = value
|
|
}
|
|
|
|
if case .setTo(let value) = values.shouldBeVisible, value != result.shouldBeVisible {
|
|
requiredChanges.append(SessionThread.Columns.shouldBeVisible.set(to: value))
|
|
finalShouldBeVisible = value
|
|
}
|
|
|
|
/// If no changes were needed we can just return the existing/default thread
|
|
guard !requiredChanges.isEmpty else { return result }
|
|
|
|
/// Otherwise save the changes
|
|
try SessionThread
|
|
.filter(id: id)
|
|
.updateAllAndConfig(
|
|
db,
|
|
requiredChanges,
|
|
calledFromConfig: (configTriggeringChange != nil),
|
|
using: dependencies
|
|
)
|
|
|
|
/// We need to re-fetch the updated thread as the changes wouldn't have been applied to `result`, it's also possible additional
|
|
/// changes could have happened to the thread during the database operations
|
|
///
|
|
/// Since we want to avoid returning a nullable `SessionThread` here we need to fallback to a non-null instance, but it should
|
|
/// never be called
|
|
return (try fetchOne(db, id: id))
|
|
.defaulting(
|
|
to: try SessionThread(
|
|
id: id,
|
|
variant: variant,
|
|
creationDateTimestamp: values.creationDateTimestamp,
|
|
shouldBeVisible: finalShouldBeVisible,
|
|
pinnedPriority: finalPinnedPriority
|
|
).upserted(db)
|
|
)
|
|
}
|
|
|
|
static func canSendReadReceipt(
|
|
_ db: Database,
|
|
threadId: String,
|
|
threadVariant maybeThreadVariant: SessionThread.Variant? = nil,
|
|
isBlocked maybeIsBlocked: Bool? = nil,
|
|
isMessageRequest maybeIsMessageRequest: Bool? = nil
|
|
) throws -> Bool {
|
|
let threadVariant: SessionThread.Variant = try {
|
|
try maybeThreadVariant ??
|
|
SessionThread
|
|
.filter(id: threadId)
|
|
.select(.variant)
|
|
.asRequest(of: SessionThread.Variant.self)
|
|
.fetchOne(db, orThrow: StorageError.objectNotFound)
|
|
}()
|
|
let threadIsBlocked: Bool = try {
|
|
try maybeIsBlocked ??
|
|
(
|
|
threadVariant == .contact &&
|
|
Contact
|
|
.filter(id: threadId)
|
|
.select(.isBlocked)
|
|
.asRequest(of: Bool.self)
|
|
.fetchOne(db, orThrow: StorageError.objectNotFound)
|
|
)
|
|
}()
|
|
let threadIsMessageRequest: Bool = SessionThread
|
|
.filter(id: threadId)
|
|
.filter(
|
|
SessionThread.isMessageRequest(
|
|
userPublicKey: getUserHexEncodedPublicKey(db),
|
|
includeNonVisible: true
|
|
)
|
|
)
|
|
.isNotEmpty(db)
|
|
|
|
return (
|
|
!threadIsBlocked &&
|
|
!threadIsMessageRequest
|
|
)
|
|
}
|
|
|
|
@available(*, unavailable, message: "should not be used until pin re-ordering is built")
|
|
static func refreshPinnedPriorities(_ db: Database, adding threadId: String) throws {
|
|
struct PinnedPriority: TableRecord, ColumnExpressible {
|
|
public typealias Columns = CodingKeys
|
|
public enum CodingKeys: String, CodingKey, ColumnExpression {
|
|
case id
|
|
case rowIndex
|
|
}
|
|
}
|
|
|
|
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
|
let pinnedPriority: TypedTableAlias<PinnedPriority> = TypedTableAlias()
|
|
let rowIndexLiteral: SQL = SQL(stringLiteral: PinnedPriority.Columns.rowIndex.name)
|
|
let pinnedPriorityLiteral: SQL = SQL(stringLiteral: SessionThread.Columns.pinnedPriority.name)
|
|
|
|
try db.execute(literal: """
|
|
WITH \(PinnedPriority.self) AS (
|
|
SELECT
|
|
\(thread[.id]),
|
|
ROW_NUMBER() OVER (
|
|
ORDER BY \(SQL("\(thread[.id]) != \(threadId)")),
|
|
\(thread[.pinnedPriority]) ASC
|
|
) AS \(rowIndexLiteral)
|
|
FROM \(SessionThread.self)
|
|
WHERE
|
|
\(thread[.pinnedPriority]) > 0 OR
|
|
\(SQL("\(thread[.id]) = \(threadId)"))
|
|
)
|
|
|
|
UPDATE \(SessionThread.self)
|
|
SET \(pinnedPriorityLiteral) = (
|
|
SELECT \(pinnedPriority[.rowIndex])
|
|
FROM \(PinnedPriority.self)
|
|
WHERE \(pinnedPriority[.id]) = \(thread[.id])
|
|
)
|
|
""")
|
|
}
|
|
}
|
|
|
|
// MARK: - Deletion
|
|
|
|
public extension SessionThread {
|
|
enum DeletionType {
|
|
case hideContactConversation
|
|
case hideContactConversationAndDeleteContentDirectly
|
|
case deleteContactConversationAndMarkHidden
|
|
case deleteContactConversationAndContact
|
|
case leaveGroupAsync
|
|
case deleteGroupAndContent
|
|
case deleteCommunityAndContent
|
|
}
|
|
|
|
static func deleteOrLeave(
|
|
_ db: Database,
|
|
type: SessionThread.DeletionType,
|
|
threadId: String,
|
|
calledFromConfigHandling: Bool,
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
try deleteOrLeave(
|
|
db,
|
|
type: type,
|
|
threadIds: [threadId],
|
|
calledFromConfigHandling: calledFromConfigHandling,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
static func deleteOrLeave(
|
|
_ db: Database,
|
|
type: SessionThread.DeletionType,
|
|
threadIds: [String],
|
|
calledFromConfigHandling: Bool,
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
|
let remainingThreadIds: Set<String> = threadIds.asSet().removing(currentUserPublicKey)
|
|
|
|
switch type {
|
|
case .hideContactConversation:
|
|
_ = try SessionThread
|
|
.filter(ids: threadIds)
|
|
.updateAllAndConfig(
|
|
db,
|
|
SessionThread.Columns.pinnedPriority.set(to: LibSession.hiddenPriority),
|
|
SessionThread.Columns.shouldBeVisible.set(to: false),
|
|
calledFromConfig: calledFromConfigHandling,
|
|
using: dependencies
|
|
)
|
|
|
|
case .hideContactConversationAndDeleteContentDirectly:
|
|
// Clear any interactions for the deleted thread
|
|
_ = try Interaction
|
|
.filter(threadIds.contains(Interaction.Columns.threadId))
|
|
.deleteAll(db)
|
|
|
|
// Hide the threads
|
|
_ = try SessionThread
|
|
.filter(ids: threadIds)
|
|
.updateAllAndConfig(
|
|
db,
|
|
SessionThread.Columns.pinnedPriority.set(to: LibSession.hiddenPriority),
|
|
SessionThread.Columns.shouldBeVisible.set(to: false),
|
|
calledFromConfig: calledFromConfigHandling,
|
|
using: dependencies
|
|
)
|
|
|
|
case .deleteContactConversationAndMarkHidden:
|
|
_ = try SessionThread
|
|
.filter(ids: remainingThreadIds)
|
|
.deleteAll(db)
|
|
|
|
// We need to custom handle the 'Note to Self' conversation (it should just be
|
|
// hidden locally rather than deleted)
|
|
if threadIds.contains(currentUserPublicKey) {
|
|
// Clear any interactions for the deleted thread
|
|
_ = try Interaction
|
|
.filter(Interaction.Columns.threadId == currentUserPublicKey)
|
|
.deleteAll(db)
|
|
|
|
_ = try SessionThread
|
|
.filter(id: currentUserPublicKey)
|
|
.updateAllAndConfig(
|
|
db,
|
|
SessionThread.Columns.pinnedPriority.set(to: LibSession.hiddenPriority),
|
|
SessionThread.Columns.shouldBeVisible.set(to: false),
|
|
calledFromConfig: calledFromConfigHandling,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
if !calledFromConfigHandling {
|
|
// Update any other threads to be hidden
|
|
try LibSession.hide(db, contactIds: Array(remainingThreadIds), using: dependencies)
|
|
}
|
|
|
|
case .deleteContactConversationAndContact:
|
|
// If this wasn't called from config handling then we need to hide the conversation
|
|
if !calledFromConfigHandling {
|
|
try LibSession.remove(db, contactIds: Array(remainingThreadIds), using: dependencies)
|
|
}
|
|
|
|
_ = try SessionThread
|
|
.filter(ids: remainingThreadIds)
|
|
.deleteAll(db)
|
|
|
|
case .leaveGroupAsync:
|
|
try threadIds.forEach { threadId in
|
|
try MessageSender.leave(
|
|
db,
|
|
groupPublicKey: threadId,
|
|
deleteThread: true
|
|
)
|
|
}
|
|
|
|
case .deleteGroupAndContent:
|
|
try ClosedGroup.removeKeysAndUnsubscribe(
|
|
db,
|
|
threadIds: threadIds,
|
|
removeGroupData: true,
|
|
calledFromConfigHandling: calledFromConfigHandling,
|
|
using: dependencies
|
|
)
|
|
|
|
case .deleteCommunityAndContent:
|
|
threadIds.forEach { threadId in
|
|
OpenGroupManager.shared.delete(
|
|
db,
|
|
openGroupId: threadId,
|
|
calledFromConfigHandling: calledFromConfigHandling
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
)
|
|
"""
|
|
}
|
|
|
|
static func unreadMessageRequestsCountQuery(userPublicKey: String, includeNonVisible: Bool = false) -> SQLRequest<Int> {
|
|
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
|
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
|
|
|
return """
|
|
SELECT COUNT(DISTINCT id) FROM (
|
|
SELECT \(thread[.id]) AS 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))
|
|
)
|
|
)
|
|
"""
|
|
}
|
|
|
|
/// 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) -> SQLExpression {
|
|
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
|
|
"""
|
|
).sqlExpression
|
|
}
|
|
|
|
func isMessageRequest(_ db: Database, includeNonVisible: Bool = false) -> Bool {
|
|
return SessionThread.isMessageRequest(
|
|
id: id,
|
|
variant: variant,
|
|
currentUserPublicKey: getUserHexEncodedPublicKey(db),
|
|
shouldBeVisible: shouldBeVisible,
|
|
contactIsApproved: (try? Contact
|
|
.filter(id: id)
|
|
.select(.isApproved)
|
|
.asRequest(of: Bool.self)
|
|
.fetchOne(db))
|
|
.defaulting(to: false),
|
|
includeNonVisible: includeNonVisible
|
|
)
|
|
}
|
|
|
|
static func isMessageRequest(
|
|
id: String,
|
|
variant: SessionThread.Variant?,
|
|
currentUserPublicKey: String,
|
|
shouldBeVisible: Bool?,
|
|
contactIsApproved: Bool?,
|
|
includeNonVisible: Bool = false
|
|
) -> Bool {
|
|
return (
|
|
(includeNonVisible || shouldBeVisible == true) &&
|
|
variant == .contact &&
|
|
id != currentUserPublicKey && // Note to self
|
|
((contactIsApproved ?? 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 numInteractions: Int = {
|
|
switch interaction.serverHash {
|
|
case .some(let serverHash):
|
|
return (try? self.interactions
|
|
.filter(Interaction.Columns.serverHash != serverHash)
|
|
.fetchCount(db))
|
|
.defaulting(to: 0)
|
|
|
|
case .none:
|
|
return (try? self.interactions
|
|
.filter(Interaction.Columns.timestampMs != interaction.timestampMs)
|
|
.fetchCount(db))
|
|
.defaulting(to: 0)
|
|
}
|
|
}()
|
|
|
|
// We only want to show a notification for the first interaction in the thread
|
|
guard numInteractions == 0 else { return false }
|
|
|
|
// Need to re-show the message requests section if it had been hidden
|
|
if db[.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 .legacyGroup, .group: return (closedGroupName ?? "groupUnknown".localized())
|
|
case .community: return (openGroupName ?? "communityUnknown".localized())
|
|
case .contact:
|
|
guard !isNoteToSelf else { return "noteToSelf".localized() }
|
|
guard let profile: Profile = profile else {
|
|
return Profile.truncated(id: threadId, truncating: .middle)
|
|
}
|
|
|
|
return profile.displayName()
|
|
}
|
|
}
|
|
|
|
static func getUserHexEncodedBlindedKey(
|
|
_ db: Database? = nil,
|
|
threadId: String,
|
|
threadVariant: Variant,
|
|
blindingPrefix: SessionId.Prefix,
|
|
using dependencies: Dependencies = Dependencies()
|
|
) -> String? {
|
|
guard threadVariant == .community else { return nil }
|
|
guard let db: Database = db else {
|
|
return dependencies.storage.read { db in
|
|
getUserHexEncodedBlindedKey(
|
|
db,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
blindingPrefix: blindingPrefix,
|
|
using: dependencies
|
|
)
|
|
}
|
|
}
|
|
|
|
// Retrieve the relevant open group info
|
|
struct OpenGroupInfo: Decodable, FetchableRecord {
|
|
let publicKey: String
|
|
let server: String
|
|
}
|
|
|
|
guard
|
|
let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db),
|
|
let openGroupInfo: OpenGroupInfo = try? OpenGroup
|
|
.filter(id: threadId)
|
|
.select(.publicKey, .server)
|
|
.asRequest(of: OpenGroupInfo.self)
|
|
.fetchOne(db)
|
|
else { return nil }
|
|
|
|
// Check the capabilities to ensure the SOGS is blinded (or whether we have no capabilities)
|
|
let capabilities: Set<Capability.Variant> = (try? Capability
|
|
.select(.variant)
|
|
.filter(Capability.Columns.openGroupServer == openGroupInfo.server.lowercased())
|
|
.asRequest(of: Capability.Variant.self)
|
|
.fetchSet(db))
|
|
.defaulting(to: [])
|
|
|
|
guard capabilities.isEmpty || capabilities.contains(.blind) else { return nil }
|
|
|
|
let blindedKeyPair: KeyPair? = dependencies.crypto.generate(
|
|
.blinded15KeyPair(
|
|
serverPublicKey: openGroupInfo.publicKey,
|
|
ed25519SecretKey: userEdKeyPair.secretKey
|
|
)
|
|
)
|
|
|
|
return blindedKeyPair.map { keyPair -> String in
|
|
SessionId(blindingPrefix, publicKey: keyPair.publicKey).hexString
|
|
}
|
|
}
|
|
}
|