Files
Morgan Pretty 37e66498fd Merge branch 'master' into dev
# Conflicts:
#	Session/Conversations/ConversationVC+Interaction.swift
#	Session/Conversations/Settings/ThreadSettingsViewModel.swift
#	Session/Home/HomeVC.swift
#	Session/Notifications/NotificationPresenter.swift
#	Session/Onboarding/Onboarding.swift
#	Session/Utilities/UIContextualAction+Utilities.swift
#	SessionMessagingKit/Database/Models/ClosedGroup.swift
#	SessionMessagingKit/Database/Models/Interaction.swift
#	SessionMessagingKit/Database/Models/SessionThread.swift
#	SessionMessagingKit/Open Groups/OpenGroupManager.swift
#	SessionMessagingKit/Sending & Receiving/MessageReceiver.swift
#	SessionMessagingKit/Shared Models/SessionThreadViewModel.swift
#	SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift
#	SessionShareExtension/ThreadPickerVC.swift
2026-01-20 16:33:31 +11:00

943 lines
39 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
import SessionNetworkingKit
public struct SessionThread: Sendable, Codable, Identifiable, Equatable, Hashable, PagableRecord, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, IdentifiableTableRecord {
public typealias PagedDataType = SessionThread
public static var databaseTableName: String { "thread" }
public static let idColumn: ColumnExpression = Columns.id
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case id
case variant
case creationDateTimestamp
case shouldBeVisible
case messageDraft
case notificationSound
case mutedUntilTimestamp
case onlyNotifyForMentions
case markedAsUnread
case pinnedPriority
case isDraft
}
public enum Variant: Int, Sendable, 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
/// 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?
/// A value indicating whether this conversation is a draft conversation (ie. hasn't sent a message yet and should auto-delete)
public let isDraft: Bool?
// 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,
isDraft: Bool? = 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.isDraft = isDraft
self.pinnedPriority = ((pinnedPriority ?? 0) > 0 ? pinnedPriority :
(isPinned ? 1 : 0)
)
}
// MARK: - Custom Database Interaction
public func aroundInsert(_ db: Database, insert: () throws -> InsertionSuccess) throws {
_ = try insert()
/// If this thread was created during onboarding then we don't want to set `hasSavedThread` or send a conversation
/// event (as this is likely the "Note to Self" thread or some future "initial" state which wasn't a user-driven change)
if ThreadCreationContext.isOnboarding != true {
switch ObservationContext.observingDb {
case .none: Log.error("[SessionThread] Could not process 'aroundInsert' due to missing observingDb.")
case .some(let observingDb):
observingDb.dependencies.setAsync(.hasSavedThread, true)
observingDb.addConversationEvent(
id: id,
variant: variant,
type: .created
)
}
}
}
}
// MARK: - Codable
public extension SessionThread {
init(from decoder: any Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
let pinnedPriority: Int32? = try container.decodeIfPresent(Int32.self, forKey: .pinnedPriority)
self = SessionThread(
id: try container.decode(String.self, forKey: .id),
variant: try container.decode(Variant.self, forKey: .variant),
creationDateTimestamp: try container.decode(TimeInterval.self, forKey: .creationDateTimestamp),
shouldBeVisible: try container.decode(Bool.self, forKey: .shouldBeVisible),
isPinned: ((pinnedPriority ?? 0) > 0),
messageDraft: try container.decodeIfPresent(String.self, forKey: .messageDraft),
notificationSound: try container.decodeIfPresent(Preferences.Sound.self, forKey: .notificationSound),
mutedUntilTimestamp: try container.decodeIfPresent(TimeInterval.self, forKey: .mutedUntilTimestamp),
onlyNotifyForMentions: try container.decode(Bool.self, forKey: .onlyNotifyForMentions),
markedAsUnread: try container.decodeIfPresent(Bool.self, forKey: .markedAsUnread),
pinnedPriority: pinnedPriority,
isDraft: try container.decodeIfPresent(Bool.self, forKey: .isDraft)
)
}
}
// 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
/// If the thread doesn't exist then the provided value will be used, if it does exist then the existing value will be used
case useExistingOrSetTo(T)
var valueOrNull: T? {
switch self {
case .setTo(let value), .useExistingOrSetTo(let value): return value
default: return nil
}
}
var shouldUseLibSession: Bool {
switch self {
case .useLibSession: return true
default: return false
}
}
}
let creationDateTimestamp: Value<TimeInterval>
let shouldBeVisible: Value<Bool>
let pinnedPriority: Value<Int32>
let isDraft: Value<Bool>
let disappearingMessagesConfig: Value<DisappearingMessagesConfiguration>
let mutedUntilTimestamp: Value<TimeInterval?>
let onlyNotifyForMentions: Value<Bool>
// MARK: - Convenience
public static var existingOrDefault: TargetValues {
return TargetValues(shouldBeVisible: .useLibSession)
}
// MARK: - Initialization
public init(
creationDateTimestamp: Value<TimeInterval> = .useExisting,
shouldBeVisible: Value<Bool>,
pinnedPriority: Value<Int32> = .useLibSession,
isDraft: Value<Bool> = .useExisting,
disappearingMessagesConfig: Value<DisappearingMessagesConfiguration> = .useLibSession,
mutedUntilTimestamp: Value<TimeInterval?> = .useExisting,
onlyNotifyForMentions: Value<Bool> = .useExisting
) {
self.creationDateTimestamp = creationDateTimestamp
self.shouldBeVisible = shouldBeVisible
self.pinnedPriority = pinnedPriority
self.isDraft = isDraft
self.disappearingMessagesConfig = disappearingMessagesConfig
self.mutedUntilTimestamp = mutedUntilTimestamp
self.onlyNotifyForMentions = onlyNotifyForMentions
}
// MARK: - Functions
func resolveLibSessionValues(
_ db: ObservingDatabase,
id: ID,
variant: Variant,
using dependencies: Dependencies
) -> TargetValues {
guard
creationDateTimestamp.shouldUseLibSession ||
shouldBeVisible.shouldUseLibSession ||
pinnedPriority.shouldUseLibSession ||
isDraft.shouldUseLibSession ||
disappearingMessagesConfig.shouldUseLibSession ||
mutedUntilTimestamp.shouldUseLibSession ||
onlyNotifyForMentions.shouldUseLibSession
else { return self }
let openGroupUrlInfo: LibSession.OpenGroupUrlInfo? = (variant != .community ? nil :
try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: id)
)
return dependencies.mutate(cache: .libSession) { cache in
var shouldBeVisible: Value<Bool> = self.shouldBeVisible
var pinnedPriority: Value<Int32> = self.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 (self.pinnedPriority, self.shouldBeVisible) {
case (.useLibSession, .useLibSession):
let targetPriority: Int32 = cache.pinnedPriority(
threadId: id,
threadVariant: variant,
openGroupUrlInfo: openGroupUrlInfo
)
shouldBeVisible = .setTo(LibSession.shouldBeVisible(priority: targetPriority))
pinnedPriority = .setTo(targetPriority)
default: break
}
/// Sort out the disappearing message conifg setting
var disappearingMessagesConfig: Value<DisappearingMessagesConfiguration> = self.disappearingMessagesConfig
if
variant != .community,
disappearingMessagesConfig.shouldUseLibSession,
let config: DisappearingMessagesConfiguration = cache.disappearingMessagesConfig(
threadId: id,
threadVariant: variant
)
{
disappearingMessagesConfig = .setTo(config)
}
return TargetValues(
creationDateTimestamp: self.creationDateTimestamp,
shouldBeVisible: shouldBeVisible,
pinnedPriority: pinnedPriority,
isDraft: self.isDraft,
disappearingMessagesConfig: disappearingMessagesConfig,
mutedUntilTimestamp: self.mutedUntilTimestamp,
onlyNotifyForMentions: self.onlyNotifyForMentions
)
}
}
}
/// 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: ObservingDatabase,
id: ID,
variant: Variant,
values: TargetValues,
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 = dependencies.mutate(cache: .libSession) { cache in
let openGroupUrlInfo: LibSession.OpenGroupUrlInfo? = (variant != .community ? nil :
try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: id)
)
return cache.pinnedPriority(
threadId: id,
threadVariant: variant,
openGroupUrlInfo: openGroupUrlInfo
)
}
result = try SessionThread(
id: id,
variant: variant,
creationDateTimestamp: (
values.creationDateTimestamp.valueOrNull ??
(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000)
),
shouldBeVisible: LibSession.shouldBeVisible(priority: targetPriority),
mutedUntilTimestamp: nil,
onlyNotifyForMentions: false,
pinnedPriority: targetPriority,
isDraft: (values.isDraft.valueOrNull == true)
).upserted(db)
}
return try result.update(db, values: values, using: dependencies)
}
@discardableResult static func update(
_ db: ObservingDatabase,
id: ID,
values: TargetValues,
using dependencies: Dependencies
) throws -> SessionThread? {
guard let thread: SessionThread = try? fetchOne(db, id: id) else {
return nil
}
return try thread.update(db, values: values, using: dependencies)
}
private func update(
_ db: ObservingDatabase,
values: TargetValues,
using dependencies: Dependencies
) throws -> SessionThread {
/// Apply any changes if the provided `values` don't match the current or default settings
var requiredChanges: [ConfigColumnAssignment] = []
var finalCreationDateTimestamp: TimeInterval = creationDateTimestamp
var finalShouldBeVisible: Bool = shouldBeVisible
var finalPinnedPriority: Int32? = pinnedPriority
var finalIsDraft: Bool? = isDraft
var finalMutedUntilTimestamp: TimeInterval? = mutedUntilTimestamp
var finalOnlyNotifyForMentions: Bool = onlyNotifyForMentions
/// Resolve any settings which should be sourced from `libSession`
let resolvedValues: TargetValues = values.resolveLibSessionValues(
db,
id: id,
variant: variant,
using: dependencies
)
/// Setup the `DisappearingMessagesConfiguration` as specified
switch (variant, resolvedValues.disappearingMessagesConfig) {
case (.community, _), (_, .useExisting): break // No need to do anything
case (_, .setTo(let config)): // Save the explicit config
// Don't bother doing anything if the config hasn't changed
let localConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration
.fetchOne(db, id: id)
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(id))
guard localConfig != config else { break }
try config
.upserted(db)
.clearUnrelatedControlMessages(
db,
threadVariant: variant,
using: dependencies
)
/// Notify of update
db.addConversationEvent(
id: id,
variant: variant,
type: .updated(.disappearingMessageConfiguration(config))
)
case (_, .useExistingOrSetTo(let config)): // Update if we don't have an existing entry
guard (try? DisappearingMessagesConfiguration.exists(db, id: id)) == false else { break }
try config
.upserted(db)
.clearUnrelatedControlMessages(
db,
threadVariant: variant,
using: dependencies
)
/// Notify of update
db.addConversationEvent(
id: id,
variant: variant,
type: .updated(.disappearingMessageConfiguration(config))
)
case (_, .useLibSession): break // Shouldn't happen
}
/// And update any explicit `setTo` cases
if case .setTo(let value) = values.creationDateTimestamp, value != creationDateTimestamp {
requiredChanges.append(SessionThread.Columns.creationDateTimestamp.set(to: value))
finalCreationDateTimestamp = value
}
if case .setTo(let value) = values.shouldBeVisible, value != shouldBeVisible {
requiredChanges.append(SessionThread.Columns.shouldBeVisible.set(to: value))
finalShouldBeVisible = value
db.addConversationEvent(
id: id,
variant: variant,
type: .updated(.shouldBeVisible(value))
)
/// Toggling visibility is the same as "creating"/"deleting" a conversation so send those events as well
db.addConversationEvent(
id: id,
variant: variant,
type: (value ? .created : .deleted)
)
/// Need an explicit event for deleting a message request to trigger a home screen update
if !value && dependencies.mutate(cache: .libSession, { $0.isMessageRequest(threadId: id, threadVariant: variant) }) {
db.addEvent(.messageRequestDeleted)
}
}
if case .setTo(let value) = values.pinnedPriority, value != pinnedPriority {
requiredChanges.append(SessionThread.Columns.pinnedPriority.set(to: value))
finalPinnedPriority = value
db.addConversationEvent(
id: id,
variant: variant,
type: .updated(.pinnedPriority(value))
)
}
if case .setTo(let value) = values.isDraft, value != isDraft {
requiredChanges.append(SessionThread.Columns.isDraft.set(to: value))
finalIsDraft = value
db.addConversationEvent(
id: id,
variant: variant,
type: .updated(.isDraft(value))
)
}
if case .setTo(let value) = values.mutedUntilTimestamp, value != mutedUntilTimestamp {
requiredChanges.append(SessionThread.Columns.mutedUntilTimestamp.set(to: value))
finalMutedUntilTimestamp = value
db.addConversationEvent(
id: id,
variant: variant,
type: .updated(.mutedUntilTimestamp(value))
)
}
if case .setTo(let value) = values.onlyNotifyForMentions, value != onlyNotifyForMentions {
requiredChanges.append(SessionThread.Columns.onlyNotifyForMentions.set(to: value))
finalOnlyNotifyForMentions = value
db.addConversationEvent(
id: id,
variant: variant,
type: .updated(.onlyNotifyForMentions(value))
)
}
/// If no changes were needed we can just return the existing/default thread
guard !requiredChanges.isEmpty else { return self }
/// Otherwise save the changes
try SessionThread
.filter(id: id)
.updateAllAndConfig(
db,
requiredChanges,
using: dependencies
)
/// Return and updated instance
return self.with(
creationDateTimestamp: .set(to: finalCreationDateTimestamp),
shouldBeVisible: .set(to: finalShouldBeVisible),
mutedUntilTimestamp: .set(to: finalMutedUntilTimestamp),
onlyNotifyForMentions: .set(to: finalOnlyNotifyForMentions),
pinnedPriority: .set(to: finalPinnedPriority),
isDraft: .set(to: finalIsDraft)
)
}
static func canSendReadReceipt(
threadId: String,
threadVariant: SessionThread.Variant,
using dependencies: Dependencies
) throws -> Bool {
return dependencies.mutate(cache: .libSession) { libSession in
!libSession.isContactBlocked(contactId: threadId) &&
!libSession.isMessageRequest(threadId: threadId, threadVariant: threadVariant)
}
}
@available(*, unavailable, message: "should not be used until pin re-ordering is built")
static func refreshPinnedPriorities(_ db: ObservingDatabase, 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])
)
""")
}
// stringlint:ignore_contents
static func interactionInfoWithAttachments(
threadId: String,
beforeTimestampMs: Int64? = nil,
attachmentVariants: [Attachment.Variant] = Attachment.Variant.allCases
) throws -> SQLRequest<Interaction.VariantInfo> {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
return """
SELECT
\(interaction[.id]),
\(interaction[.variant]),
\(interaction[.serverHash])
FROM \(interaction)
JOIN \(interactionAttachment) ON \(interactionAttachment[.interactionId]) = \(interaction[.id])
JOIN \(attachment) ON (
\(attachment[.id]) = \(interactionAttachment[.attachmentId]) AND
\(attachment[.variant]) IN \(attachmentVariants)
)
WHERE (
\(interaction[.threadId]) = \(threadId) AND
\(interaction[.timestampMs]) < \(beforeTimestampMs ?? Int64.max)
)
GROUP BY \(interaction[.id])
"""
}
}
// MARK: - Deletion
public extension SessionThread {
enum DeletionType {
case hideContactConversation
case hideContactConversationAndDeleteContentDirectly
case deleteContactConversationAndMarkHidden
case deleteContactConversationAndContact
case leaveGroupAsync
case deleteGroupAndContentForEveryoneAsync
case deleteGroupAndContent
case deleteCommunityAndContent
}
static func deleteOrLeave(
_ db: ObservingDatabase,
type: SessionThread.DeletionType,
threadId: String,
threadVariant: Variant,
using dependencies: Dependencies
) throws {
try deleteOrLeave(
db,
type: type,
threadIds: [threadId],
threadVariant: threadVariant,
using: dependencies
)
}
static func deleteOrLeave(
_ db: ObservingDatabase,
type: SessionThread.DeletionType,
threadIds: [String],
threadVariant: Variant,
using dependencies: Dependencies
) throws {
let userSessionId: SessionId = dependencies[cache: .general].sessionId
let remainingThreadIds: Set<String> = threadIds.asSet().removing(userSessionId.hexString)
switch type {
case .hideContactConversation:
try threadIds.forEach { id in
try SessionThread.update(
db,
id: id,
values: SessionThread.TargetValues(
shouldBeVisible: .setTo(false)
),
using: dependencies
)
}
case .hideContactConversationAndDeleteContentDirectly:
// Clear any interactions for the deleted thread
try Interaction.deleteWhere(
db,
.filter(threadIds.contains(Interaction.Columns.threadId))
)
// Hide the threads
try threadIds.forEach { id in
try SessionThread.update(
db,
id: id,
values: SessionThread.TargetValues(
shouldBeVisible: .setTo(false)
),
using: dependencies
)
}
// Remove desired deduplication records
try MessageDeduplication.deleteIfNeeded(db, threadIds: threadIds, using: dependencies)
case .deleteContactConversationAndMarkHidden:
// Clear any interactions for the deleted thread
try Interaction.deleteWhere(
db,
.filter(remainingThreadIds.contains(Interaction.Columns.threadId))
)
try SessionThread
.filter(ids: remainingThreadIds)
.deleteAll(db)
let messageRequestMap: [String: Bool] = dependencies.mutate(cache: .libSession) { libSession in
remainingThreadIds
.map { ($0, libSession.isMessageRequest(threadId: $0, threadVariant: threadVariant)) }
.reduce(into: [:]) { result, next in result[next.0] = next.1 }
}
remainingThreadIds.forEach { id in
db.addConversationEvent(
id: id,
variant: threadVariant,
type: .deleted
)
/// Need an explicit event for deleting a message request to trigger a home screen update
if messageRequestMap[id] == true {
db.addEvent(.messageRequestDeleted)
}
}
// We need to custom handle the 'Note to Self' conversation (it should just be
// hidden locally rather than deleted)
if threadIds.contains(userSessionId.hexString) {
// Clear any interactions for the deleted thread
try Interaction.deleteWhere(
db,
.filter(Interaction.Columns.threadId == userSessionId.hexString)
)
try threadIds.forEach { id in
try SessionThread.update(
db,
id: id,
values: SessionThread.TargetValues(
shouldBeVisible: .setTo(false)
),
using: dependencies
)
}
}
// Remove desired deduplication records
try MessageDeduplication.deleteIfNeeded(db, threadIds: threadIds, using: dependencies)
// Update any other threads to be hidden
try LibSession.hide(db, contactIds: Array(remainingThreadIds), using: dependencies)
case .deleteContactConversationAndContact:
// Remove the contact from the config (also need to clear the nickname since that's
// custom data for this contact)
try LibSession.remove(db, contactIds: Array(remainingThreadIds), using: dependencies)
try Profile
.filter(ids: remainingThreadIds)
.updateAll(db, Profile.Columns.nickname.set(to: nil))
try Contact
.filter(ids: remainingThreadIds)
.deleteAll(db)
try Interaction.deleteWhere(
db,
.filter(remainingThreadIds.contains(Interaction.Columns.threadId))
)
try SessionThread
.filter(ids: remainingThreadIds)
.deleteAll(db)
let messageRequestMap: [String: Bool] = dependencies.mutate(cache: .libSession) { libSession in
remainingThreadIds
.map { ($0, libSession.isMessageRequest(threadId: $0, threadVariant: threadVariant)) }
.reduce(into: [:]) { result, next in result[next.0] = next.1 }
}
remainingThreadIds.forEach { id in
db.addConversationEvent(
id: id,
variant: threadVariant,
type: .deleted
)
/// Need an explicit event for deleting a message request to trigger a home screen update
if messageRequestMap[id] == true {
db.addEvent(.messageRequestDeleted)
}
}
// Remove desired deduplication records
try MessageDeduplication.deleteIfNeeded(db, threadIds: threadIds, using: dependencies)
case .leaveGroupAsync:
try threadIds.forEach { threadId in
try MessageSender.leaveGroup(
db,
threadId: threadId,
threadVariant: threadVariant,
behaviour: .leave,
using: dependencies
)
}
case .deleteGroupAndContentForEveryoneAsync:
try threadIds.forEach { threadId in
try MessageSender.leaveGroup(
db,
threadId: threadId,
threadVariant: threadVariant,
behaviour: .delete,
using: dependencies
)
}
case .deleteGroupAndContent:
try ClosedGroup.removeData(
db,
threadIds: threadIds,
dataToRemove: .allData,
using: dependencies
)
case .deleteCommunityAndContent:
try threadIds.forEach { threadId in
try dependencies[singleton: .communityManager].delete(
db,
openGroupId: threadId,
skipLibSessionUpdate: false
)
}
}
}
}
// MARK: - Convenience
public extension SessionThread {
func with(
creationDateTimestamp: Update<TimeInterval> = .useExisting,
shouldBeVisible: Update<Bool> = .useExisting,
messageDraft: Update<String?> = .useExisting,
mutedUntilTimestamp: Update<TimeInterval?> = .useExisting,
onlyNotifyForMentions: Update<Bool> = .useExisting,
markedAsUnread: Update<Bool?> = .useExisting,
pinnedPriority: Update<Int32?> = .useExisting,
isDraft: Update<Bool?> = .useExisting
) -> SessionThread {
return SessionThread(
id: id,
variant: variant,
creationDateTimestamp: creationDateTimestamp.or(self.creationDateTimestamp),
shouldBeVisible: shouldBeVisible.or(self.shouldBeVisible),
messageDraft: messageDraft.or(self.messageDraft),
mutedUntilTimestamp: mutedUntilTimestamp.or(self.mutedUntilTimestamp),
onlyNotifyForMentions: onlyNotifyForMentions.or(self.onlyNotifyForMentions),
markedAsUnread: markedAsUnread.or(self.markedAsUnread),
pinnedPriority: pinnedPriority.or(self.pinnedPriority),
isDraft: isDraft.or(self.isDraft)
)
}
static func unreadMessageRequestsQuery(messageRequestThreadIds: Set<String>) -> SQLRequest<Int> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return """
SELECT DISTINCT \(thread[.id])
FROM \(SessionThread.self)
JOIN \(Interaction.self) ON (
\(interaction[.threadId]) = \(thread[.id]) AND
\(interaction[.wasRead]) = false AND
\(interaction[.variant]) IN \(Interaction.Variant.variantsToIncrementUnreadCount)
)
WHERE \(thread[.id]) IN \(messageRequestThreadIds)
"""
}
func isNoteToSelf(using dependencies: Dependencies) -> Bool {
return (
variant == .contact &&
id == dependencies[cache: .general].sessionId.hexString
)
}
static func displayName(
threadId: String,
variant: Variant,
groupName: String?,
communityName: String?,
isNoteToSelf: Bool,
ignoreNickname: Bool,
profile: Profile?
) -> String {
switch variant {
case .legacyGroup, .group: return (groupName?.nullIfEmpty ?? "groupUnknown".localized())
case .community: return (communityName?.nullIfEmpty ?? "communityUnknown".localized())
case .contact:
guard !isNoteToSelf else { return "noteToSelf".localized() }
guard let profile: Profile = profile else { return threadId.truncated() }
return profile.displayName(ignoreNickname: ignoreNickname)
}
}
static func getCurrentUserBlindedSessionId(
threadId: String,
threadVariant: Variant,
blindingPrefix: SessionId.Prefix,
openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo?,
using dependencies: Dependencies
) -> SessionId? {
guard
threadVariant == .community,
let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = openGroupCapabilityInfo
else { return nil }
return getCurrentUserBlindedSessionId(
publicKey: openGroupCapabilityInfo.publicKey,
blindingPrefix: blindingPrefix,
capabilities: openGroupCapabilityInfo.capabilities,
using: dependencies
)
}
static func getCurrentUserBlindedSessionId(
publicKey: String,
blindingPrefix: SessionId.Prefix,
capabilities: Set<Capability.Variant>,
using dependencies: Dependencies
) -> SessionId? {
/// Check the capabilities to ensure the SOGS is blinded (or whether we have no capabilities)
guard capabilities.isEmpty || capabilities.contains(.blind) else { return nil }
switch blindingPrefix {
case .blinded15:
return dependencies[singleton: .crypto]
.generate(
.blinded15KeyPair(
serverPublicKey: publicKey,
ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey
)
)
.map { SessionId(.blinded15, publicKey: $0.publicKey) }
case .blinded25:
return dependencies[singleton: .crypto]
.generate(
.blinded25KeyPair(
serverPublicKey: publicKey,
ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey
)
)
.map { SessionId(.blinded25, publicKey: $0.publicKey) }
default: return nil
}
}
}
// MARK: - Truncation
public extension String {
/// A standardised mechanism for truncating a user id for a given thread
func truncated(threadVariant: SessionThread.Variant) -> String {
return truncated(prefix: 4, suffix: 4)
}
}
// MARK: - ThreadCreationContext
public enum ThreadCreationContext {
/// This `TaskLocal` variable is set and accessible within the context of a single `Task` and allows any code running within
/// the task to access the isntance without running into threading issues or needing to manage multiple instances
@TaskLocal
public static var isOnboarding: Bool?
}